From d7bbe1425a55eee32964ca31ec3d99027918a72c Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 19 May 2026 23:25:51 +0200 Subject: [PATCH 01/45] rpc/jsonrpc, node/ethconfig: enable FcuBackgroundCommit by default Routes head-sensitive RPC reads (ReadCurrentHeader, ReadHeadHeaderHash, GetLatestBlockNumber) through the SharedDomains overlay via existing Filters.WithOverlay/WithTemporalOverlay, so the FCU response can return before the MDBX commit lands without RPC consumers seeing stale chain heads. Coherent cache for remote rpcdaemon already receives pre-commit StateChanges and serves the matching state-version root. See erigontech/erigon#21008 for the motivating regression (~+80ms p50 FCU latency + multi-second burst tails on main vs release/3.4). Co-Authored-By: Claude Opus 4.7 (1M context) --- node/ethconfig/config.go | 14 +++++++++++--- rpc/jsonrpc/bor_api_impl.go | 20 ++++++++++++-------- rpc/jsonrpc/debug_api.go | 2 +- rpc/jsonrpc/debug_execution_witness.go | 2 +- rpc/jsonrpc/erigon_block.go | 2 +- rpc/jsonrpc/erigon_receipts.go | 4 ++-- rpc/jsonrpc/eth_block.go | 2 +- rpc/jsonrpc/eth_call.go | 4 ++-- rpc/jsonrpc/eth_receipts.go | 2 +- rpc/jsonrpc/eth_simulation.go | 2 +- rpc/jsonrpc/eth_system.go | 13 +++++++------ rpc/jsonrpc/eth_txs.go | 2 +- rpc/jsonrpc/graphql_api.go | 2 +- rpc/jsonrpc/overlay_api.go | 2 +- rpc/jsonrpc/parity_api.go | 2 +- rpc/jsonrpc/trace_filtering.go | 3 ++- rpc/jsonrpc/txpool_api.go | 4 ++-- 17 files changed, 48 insertions(+), 34 deletions(-) diff --git a/node/ethconfig/config.go b/node/ethconfig/config.go index 0ed826ab9ec..07a25ee3034 100644 --- a/node/ethconfig/config.go +++ b/node/ethconfig/config.go @@ -111,9 +111,17 @@ var Defaults = Config{ ProduceE2: true, ProduceE3: true, }, - FcuTimeout: 1 * time.Second, - FcuBackgroundPrune: true, - FcuBackgroundCommit: false, // to enable, we need to 1) have rawdb API go via execctx and 2) revive Coherent cache for rpcdaemon + FcuTimeout: 1 * time.Second, + FcuBackgroundPrune: true, + // FcuBackgroundCommit lets the FCU response return to the consensus client + // before MDBX commit lands. Notifications are dispatched pre-commit from the + // SharedDomains overlay (see notification_dispatcher.go), and embedded RPC + // reads consult the overlay via filters.LatestSD()/WithOverlay (see + // rpc/rpchelper/filters.go). Remote rpcdaemon's kvcache.Coherent receives + // pre-commit StateChanges and correctly serves the matching state-version + // root while the remote tx still observes the pre-commit MDBX state — a + // ~50ms window of consistent (lagging) reads, not divergent state. + FcuBackgroundCommit: true, ExperimentalBAL: false, } diff --git a/rpc/jsonrpc/bor_api_impl.go b/rpc/jsonrpc/bor_api_impl.go index 33ba68f8e0f..6199969e1bf 100644 --- a/rpc/jsonrpc/bor_api_impl.go +++ b/rpc/jsonrpc/bor_api_impl.go @@ -57,10 +57,11 @@ func (api *BorImpl) GetSnapshot(number *rpc.BlockNumber) (*Snapshot, error) { } defer tx.Rollback() + overlayTx := api.filters.WithOverlay(tx) // Retrieve the requested block number (or current if none requested) var header *types.Header if number == nil || *number == rpc.LatestBlockNumber { - header = rawdb.ReadCurrentHeader(tx) + header = rawdb.ReadCurrentHeader(overlayTx) } else { header, _ = api.headerByNumber(ctx, *number, tx) } @@ -102,7 +103,7 @@ func (api *BorImpl) GetAuthor(blockNrOrHash *rpc.BlockNumberOrHash) (accounts.Ad //nolint:nestif if blockNrOrHash == nil { - latestBlockNum, err2 := rpchelper.GetLatestBlockNumber(tx) + latestBlockNum, err2 := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) if err2 != nil { return accounts.NilAddress, err2 } @@ -171,10 +172,11 @@ func (api *BorImpl) GetSigners(number *rpc.BlockNumber) ([]common.Address, error } defer tx.Rollback() + overlayTx := api.filters.WithOverlay(tx) // Retrieve the requested block number (or current if none requested) var header *types.Header if number == nil || *number == rpc.LatestBlockNumber { - header = rawdb.ReadCurrentHeader(tx) + header = rawdb.ReadCurrentHeader(overlayTx) } else { header, _ = api.headerByNumber(ctx, *number, tx) } @@ -298,7 +300,7 @@ func (api *BorImpl) getLatestBlockNum(ctx context.Context) (uint64, error) { } defer tx.Rollback() - return rpchelper.GetLatestBlockNumber(tx) + return rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) } // GetSnapshotProposer retrieves the in-turn signer at a given block. @@ -311,14 +313,15 @@ func (api *BorImpl) GetSnapshotProposer(blockNrOrHash *rpc.BlockNumberOrHash) (c } defer tx.Rollback() + overlayTx := api.filters.WithOverlay(tx) var header *types.Header //nolint:nestif if blockNrOrHash == nil { - header = rawdb.ReadCurrentHeader(tx) + header = rawdb.ReadCurrentHeader(overlayTx) } else { if blockNr, ok := blockNrOrHash.Number(); ok { if blockNr == rpc.LatestBlockNumber { - header = rawdb.ReadCurrentHeader(tx) + header = rawdb.ReadCurrentHeader(overlayTx) } else { header, err = api.headerByNumber(ctx, blockNr, tx) } @@ -349,14 +352,15 @@ func (api *BorImpl) GetSnapshotProposerSequence(blockNrOrHash *rpc.BlockNumberOr } defer tx.Rollback() + overlayTx := api.filters.WithOverlay(tx) // Retrieve the requested block number (or current if none requested) var header *types.Header if blockNrOrHash == nil { - header = rawdb.ReadCurrentHeader(tx) + header = rawdb.ReadCurrentHeader(overlayTx) } else { if blockNr, ok := blockNrOrHash.Number(); ok { if blockNr == rpc.LatestBlockNumber { - header = rawdb.ReadCurrentHeader(tx) + header = rawdb.ReadCurrentHeader(overlayTx) } else { header, err = api.headerByNumber(ctx, blockNr, tx) } diff --git a/rpc/jsonrpc/debug_api.go b/rpc/jsonrpc/debug_api.go index 9780ec91a02..221ba17b515 100644 --- a/rpc/jsonrpc/debug_api.go +++ b/rpc/jsonrpc/debug_api.go @@ -105,7 +105,7 @@ func (api *DebugAPIImpl) SetHead(ctx context.Context, number hexutil.Uint64) err } defer tx.Rollback() - currentHead, err := rpchelper.GetLatestBlockNumber(tx) + currentHead, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) if err != nil { return err } diff --git a/rpc/jsonrpc/debug_execution_witness.go b/rpc/jsonrpc/debug_execution_witness.go index a1735b9536a..efab22ba941 100644 --- a/rpc/jsonrpc/debug_execution_witness.go +++ b/rpc/jsonrpc/debug_execution_witness.go @@ -1113,7 +1113,7 @@ func (api *DebugAPIImpl) buildExpectedPostState( postSdCtx.SetDeferBranchUpdates(false) // Set up to read state at current block (after execution) - latestBlock, err := rpchelper.GetLatestBlockNumber(tx) + latestBlock, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) if err != nil { return nil, nil, fmt.Errorf("failed to get latest block: %w", err) } diff --git a/rpc/jsonrpc/erigon_block.go b/rpc/jsonrpc/erigon_block.go index 58bea4ffcf8..2eef4965682 100644 --- a/rpc/jsonrpc/erigon_block.go +++ b/rpc/jsonrpc/erigon_block.go @@ -94,7 +94,7 @@ func (api *ErigonImpl) GetBlockByTimestamp(ctx context.Context, timeStamp rpc.Ti uintTimestamp := timeStamp.TurnIntoUint64() - currentHeader := rawdb.ReadCurrentHeader(tx) + currentHeader := rawdb.ReadCurrentHeader(api.filters.WithOverlay(tx)) if currentHeader == nil { return nil, errors.New("current header not found") } diff --git a/rpc/jsonrpc/erigon_receipts.go b/rpc/jsonrpc/erigon_receipts.go index 80e25c05d19..4f0eab28566 100644 --- a/rpc/jsonrpc/erigon_receipts.go +++ b/rpc/jsonrpc/erigon_receipts.go @@ -98,7 +98,7 @@ func (api *ErigonImpl) GetLogs(ctx context.Context, crit filters.FilterCriteria) } else { // Convert the RPC block numbers into internal representations - latest, err := rpchelper.GetLatestBlockNumber(tx) + latest, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) if err != nil { return nil, err } @@ -193,7 +193,7 @@ func (api *ErigonImpl) GetLatestLogs(ctx context.Context, crit filters.FilterCri end = header.Number.Uint64() } else { // Convert the RPC block numbers into internal representations - latest, err := rpchelper.GetLatestBlockNumber(tx) + latest, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_block.go b/rpc/jsonrpc/eth_block.go index 4796923d417..0ee555c0373 100644 --- a/rpc/jsonrpc/eth_block.go +++ b/rpc/jsonrpc/eth_block.go @@ -376,7 +376,7 @@ func (api *APIImpl) GetBlockTransactionCountByNumber(ctx context.Context, blockN return nil, err } - latestBlockNumber, err := rpchelper.GetLatestBlockNumber(tx) + latestBlockNumber, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_call.go b/rpc/jsonrpc/eth_call.go index 96b90566e34..a337017ab61 100644 --- a/rpc/jsonrpc/eth_call.go +++ b/rpc/jsonrpc/eth_call.go @@ -467,7 +467,7 @@ func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address co sdCtx := domains.GetCommitmentContext() sdCtx.SetDeferBranchUpdates(false) - latestBlock, err := rpchelper.GetLatestBlockNumber(roTx) + latestBlock, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(roTx)) if err != nil { return nil, err } @@ -674,7 +674,7 @@ func (api *BaseAPI) getWitness(ctx context.Context, db kv.TemporalRoDB, blockNrO return nil, fmt.Errorf("transaction index out of bounds: %d", txIndex) } - latestBlock, err := rpchelper.GetLatestBlockNumber(roTx) + latestBlock, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(roTx)) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_receipts.go b/rpc/jsonrpc/eth_receipts.go index 3544389a10a..fc993ac614f 100644 --- a/rpc/jsonrpc/eth_receipts.go +++ b/rpc/jsonrpc/eth_receipts.go @@ -169,7 +169,7 @@ func (api *APIImpl) GetLogs(ctx context.Context, crit filters.FilterCriteria) (t return nil, &rpc.CustomError{Message: errInvalidBlockRange, Code: rpc.ErrCodeInvalidParams} } if end > roaring.MaxUint32 { - latest, err := rpchelper.GetLatestBlockNumber(tx) + latest, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_simulation.go b/rpc/jsonrpc/eth_simulation.go index 7a97d838dff..84411bcbccf 100644 --- a/rpc/jsonrpc/eth_simulation.go +++ b/rpc/jsonrpc/eth_simulation.go @@ -129,7 +129,7 @@ func (api *APIImpl) SimulateV1(ctx context.Context, req SimulationRequest, block if err != nil { return nil, err } - latestBlockNumber, err := rpchelper.GetLatestBlockNumber(tx) + latestBlockNumber, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_system.go b/rpc/jsonrpc/eth_system.go index cc8cdd15b3a..105932f2740 100644 --- a/rpc/jsonrpc/eth_system.go +++ b/rpc/jsonrpc/eth_system.go @@ -121,7 +121,8 @@ func (api *APIImpl) GasPrice(ctx context.Context) (*hexutil.Big, error) { return nil, err } defer tx.Rollback() - oracle := gasprice.NewOracle(NewGasPriceOracleBackend(api.db, tx, api.BaseAPI), ethconfig.Defaults.GPO, api.gasCache, nil, api.logger.New("app", "gasPriceOracle")) + overlayTx := api.filters.WithTemporalOverlay(tx) + oracle := gasprice.NewOracle(NewGasPriceOracleBackend(api.db, overlayTx, api.BaseAPI), ethconfig.Defaults.GPO, api.gasCache, nil, api.logger.New("app", "gasPriceOracle")) tipcap, err := oracle.SuggestTipCap(ctx) gasResult := uint256.NewInt(0) @@ -129,7 +130,7 @@ func (api *APIImpl) GasPrice(ctx context.Context) (*hexutil.Big, error) { if err != nil { return nil, err } - if head := rawdb.ReadCurrentHeader(tx); head != nil && head.BaseFee != nil { + if head := rawdb.ReadCurrentHeader(overlayTx); head != nil && head.BaseFee != nil { gasResult.Add(tipcap, head.BaseFee) } @@ -143,7 +144,7 @@ func (api *APIImpl) MaxPriorityFeePerGas(ctx context.Context) (*hexutil.Big, err return nil, err } defer tx.Rollback() - oracle := gasprice.NewOracle(NewGasPriceOracleBackend(api.db, tx, api.BaseAPI), ethconfig.Defaults.GPO, api.gasCache, nil, api.logger.New("app", "gasPriceOracle")) + oracle := gasprice.NewOracle(NewGasPriceOracleBackend(api.db, api.filters.WithTemporalOverlay(tx), api.BaseAPI), ethconfig.Defaults.GPO, api.gasCache, nil, api.logger.New("app", "gasPriceOracle")) tipcap, err := oracle.SuggestTipCap(ctx) if err != nil { return nil, err @@ -166,7 +167,7 @@ func (api *APIImpl) FeeHistory(ctx context.Context, blockCount rpc.DecimalOrHex, return nil, err } defer tx.Rollback() - oracle := gasprice.NewOracle(NewGasPriceOracleBackend(api.db, tx, api.BaseAPI), ethconfig.Defaults.GPO, api.gasCache, api.feeHistoryCache, api.logger.New("app", "gasPriceOracle")) + oracle := gasprice.NewOracle(NewGasPriceOracleBackend(api.db, api.filters.WithTemporalOverlay(tx), api.BaseAPI), ethconfig.Defaults.GPO, api.gasCache, api.feeHistoryCache, api.logger.New("app", "gasPriceOracle")) oldest, reward, baseFee, gasUsed, blobBaseFee, blobGasUsedRatio, err := oracle.FeeHistory(ctx, int(blockCount), lastBlock, rewardPercentiles) if err != nil { @@ -211,7 +212,7 @@ func (api *APIImpl) BlobBaseFee(ctx context.Context) (*hexutil.Big, error) { return nil, err } defer tx.Rollback() - header := rawdb.ReadCurrentHeader(tx) + header := rawdb.ReadCurrentHeader(api.filters.WithOverlay(tx)) if header == nil || header.ExcessBlobGas == nil { return (*hexutil.Big)(common.Big0), nil } @@ -238,7 +239,7 @@ func (api *APIImpl) BaseFee(ctx context.Context) (*hexutil.Big, error) { return nil, err } defer tx.Rollback() - header := rawdb.ReadCurrentHeader(tx) + header := rawdb.ReadCurrentHeader(api.filters.WithOverlay(tx)) if header == nil { return (*hexutil.Big)(common.Big0), nil } diff --git a/rpc/jsonrpc/eth_txs.go b/rpc/jsonrpc/eth_txs.go index b21f091198d..503b823ffdb 100644 --- a/rpc/jsonrpc/eth_txs.go +++ b/rpc/jsonrpc/eth_txs.go @@ -116,7 +116,7 @@ func (api *APIImpl) GetTransactionByHash(ctx context.Context, txnHash common.Has return ethapi.NewRPCTransaction(txn, blockHash, blockTime, blockNum, txnIndex, baseFee), nil } - curHeader := rawdb.ReadCurrentHeader(tx) + curHeader := rawdb.ReadCurrentHeader(api.filters.WithOverlay(tx)) if curHeader == nil { return nil, nil } diff --git a/rpc/jsonrpc/graphql_api.go b/rpc/jsonrpc/graphql_api.go index 563fe84771e..d9358fc18de 100644 --- a/rpc/jsonrpc/graphql_api.go +++ b/rpc/jsonrpc/graphql_api.go @@ -82,7 +82,7 @@ func (api *GraphQLAPIImpl) GetLatestBlockNumber(ctx context.Context) (uint64, er return 0, err } defer tx.Rollback() - return rpchelper.GetLatestBlockNumber(tx) + return rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) } func (api *GraphQLAPIImpl) GetBlockNumberForTx(ctx context.Context, hash common.Hash) (uint64, bool, error) { diff --git a/rpc/jsonrpc/overlay_api.go b/rpc/jsonrpc/overlay_api.go index 81f037ae215..1995cdd86e7 100644 --- a/rpc/jsonrpc/overlay_api.go +++ b/rpc/jsonrpc/overlay_api.go @@ -628,7 +628,7 @@ func getBeginEnd(ctx context.Context, tx kv.Tx, api *OverlayAPIImpl, crit filter return 0, 0, fmt.Errorf("end (%d) < begin (%d)", end, begin) } if end > roaring.MaxUint32 { - latest, err := rpchelper.GetLatestBlockNumber(tx) + latest, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) if err != nil { return 0, 0, err } diff --git a/rpc/jsonrpc/parity_api.go b/rpc/jsonrpc/parity_api.go index 749e364e185..c5a65cd928d 100644 --- a/rpc/jsonrpc/parity_api.go +++ b/rpc/jsonrpc/parity_api.go @@ -73,7 +73,7 @@ func (api *ParityAPIImpl) ListStorageKeys(ctx context.Context, account common.Ad return nil, errors.New("acc not found") } - bn := rawdb.ReadCurrentBlockNumber(tx) + bn := rawdb.ReadCurrentBlockNumber(api.filters.WithOverlay(tx)) minTxNum, err := api._txNumReader.Min(ctx, tx, *bn) if err != nil { return nil, err diff --git a/rpc/jsonrpc/trace_filtering.go b/rpc/jsonrpc/trace_filtering.go index b33968c00db..91e98b0e5f8 100644 --- a/rpc/jsonrpc/trace_filtering.go +++ b/rpc/jsonrpc/trace_filtering.go @@ -343,7 +343,8 @@ func (api *TraceAPIImpl) Filter(ctx context.Context, req TraceFilterRequest, gas } if req.ToBlock == nil { - headNumber, err := api._blockReader.HeaderNumber(ctx, dbtx, rawdb.ReadHeadHeaderHash(dbtx)) + overlayTx := api.filters.WithOverlay(dbtx) + headNumber, err := api._blockReader.HeaderNumber(ctx, overlayTx, rawdb.ReadHeadHeaderHash(overlayTx)) if err != nil { return err } diff --git a/rpc/jsonrpc/txpool_api.go b/rpc/jsonrpc/txpool_api.go index d0e67948dee..bcfa0502471 100644 --- a/rpc/jsonrpc/txpool_api.go +++ b/rpc/jsonrpc/txpool_api.go @@ -110,7 +110,7 @@ func (api *TxPoolAPIImpl) Content(ctx context.Context) (map[string]map[string]ma return nil, err } - curHeader := rawdb.ReadCurrentHeader(tx) + curHeader := rawdb.ReadCurrentHeader(api.filters.WithOverlay(tx)) if curHeader == nil { return nil, nil } @@ -169,7 +169,7 @@ func (api *TxPoolAPIImpl) ContentFrom(ctx context.Context, addr common.Address) return nil, err } - curHeader := rawdb.ReadCurrentHeader(tx) + curHeader := rawdb.ReadCurrentHeader(api.filters.WithOverlay(tx)) if curHeader == nil { return nil, nil } From 58f58777c9df4e52df0e7611ae3a4cb2d2855f07 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 19 May 2026 23:43:01 +0200 Subject: [PATCH 02/45] execution/execmodule: unskip TestNotificationDispatchBackgroundCommit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skip dated to PR #20195 when bg-commit had a documented "FCU N+1 reads stale state from DB" race. The actual race is in the tester helper, not the FCU: insertPoSBlocks waits on pre-commit state-change events from the gRPC stream, then InsertChain opens an roTx and reads ReadHeader/HeadBlockHash before the bg goroutine has committed. Fix in InsertChain itself by calling ExecModule.WaitIdle (acquires then releases the FCU semaphore — the bg goroutine releases it only after commit). With foreground commit the semaphore is already free, so WaitIdle is an instant no-op. FCU sequencing was never the issue: forkchoice.go:158's TryAcquire + retryBusy-on-Busy serialises consecutive FCUs against the prior bg goroutine. The test passes under -race. Co-Authored-By: Claude Opus 4.7 (1M context) --- execution/execmodule/exec_module_test.go | 6 ------ execution/execmodule/execmoduletester/exec_module_tester.go | 5 +++++ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/execution/execmodule/exec_module_test.go b/execution/execmodule/exec_module_test.go index 1deb5178cd6..e69d32a2ca4 100644 --- a/execution/execmodule/exec_module_test.go +++ b/execution/execmodule/exec_module_test.go @@ -1035,12 +1035,6 @@ func TestNotificationDispatchForegroundCommit(t *testing.T) { // test only processes the genesis → block 1 transition to verify that // notification dispatch works correctly in the background commit path. func TestNotificationDispatchBackgroundCommit(t *testing.T) { - // Background commit creates a race: FCU N returns before commit finishes, - // so FCU N+1 reads stale state from DB. This is the known limitation that - // the API-layer "latest head pointer" coordination is designed to solve. - // Once that's implemented, remove this skip and verify the full flow. - t.Skip("background commit requires API-layer coordination (latest head pointer) to work correctly") - m := execmoduletester.New(t, execmoduletester.WithFcuBackgroundCommit()) headerCh, unsub := m.Notifications.Events.AddHeaderSubscription() diff --git a/execution/execmodule/execmoduletester/exec_module_tester.go b/execution/execmodule/execmoduletester/exec_module_tester.go index 9caca4f64df..1407ac3d458 100644 --- a/execution/execmodule/execmoduletester/exec_module_tester.go +++ b/execution/execmodule/execmoduletester/exec_module_tester.go @@ -874,6 +874,11 @@ func (emt *ExecModuleTester) InsertChain(chain *blockgen.ChainPack) error { if err := emt.insertPoSBlocks(chain); err != nil { return err } + // Under FcuBackgroundCommit, UpdateForkChoice returns Success before the + // MDBX commit lands. The state-change events insertPoSBlocks waits on are + // dispatched pre-commit. Block until the bg goroutine releases the FCU + // semaphore so the DB reads below observe the committed state. + emt.ExecModule.WaitIdle(emt.Ctx) roTx, err := emt.DB.BeginRo(emt.Ctx) if err != nil { return err From 9e61f87533a9168c3c5f2551eeac5305dd467391 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 19 May 2026 23:49:18 +0200 Subject: [PATCH 03/45] cmd/utils/app: wait for FCU bg-commit in import path before WriteHeadBlockHash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When erigon runs in import mode under FcuBackgroundCommit=true, the post-UpdateForkChoice write at the end of import_cmd.go::InsertChain races the bg-commit goroutine: it can land WriteHeadBlockHash(lvh) in MDBX before the bg goroutine flushes the overlay containing Headers / BlockHash entries for the same block. Result on next startup: ReadHeadBlockHash returns the hash, HeaderNumber(hash) returns nil, BlockReader.CurrentBlock dereferences the nil and panics (freezeblocks/block_reader.go:1409). This made hive ethereum/rpc-compat fail at runner startup of the second erigon process. The existing wait on the state-change stream only ensures the dispatcher fired (events are dispatched pre-commit from the overlay), not that MDBX has flushed. Add ExecutionModule().WaitIdle() before the Update — WaitIdle acquires/releases the FCU semaphore, blocking until the bg goroutine completes its flush+commit. No-op when bg-commit is off. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/utils/app/import_cmd.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/cmd/utils/app/import_cmd.go b/cmd/utils/app/import_cmd.go index d8c749327bb..83da2f234e5 100644 --- a/cmd/utils/app/import_cmd.go +++ b/cmd/utils/app/import_cmd.go @@ -383,7 +383,9 @@ func InsertChain(ethereum *eth.Ethereum, chain *blockgen.ChainPack, setHead bool } // UpdateForkChoice has an async commit so we need to wait to make sure - // it is completed before assuming all state changes etc are inserted + // it is completed before assuming all state changes etc are inserted. + // State-change events are dispatched pre-commit, so waiting on the stream + // only ensures the dispatcher fired — not that MDBX is flushed. var lastSeenBlock uint64 for len(insertedBlocks) > 0 { req, err := stream.Recv() @@ -411,6 +413,15 @@ func InsertChain(ethereum *eth.Ethereum, chain *blockgen.ChainPack, setHead bool } } + // Under FcuBackgroundCommit the FCU returns Success before the MDBX + // commit lands; the bg goroutine writes Headers, BlockHash, HeadBlockHash + // etc. from the overlay. Open the RW tx below only after that bg + // goroutine has released the FCU semaphore, otherwise our WriteHeadBlockHash + // can commit before the overlay flush — leaving HeadBlockHash pointing at + // a header not yet in MDBX, which crashes the next startup in + // BlockReader.CurrentBlock. + ethereum.ExecutionModule().WaitIdle(ethereum.SentryCtx()) + return ethereum.ChainDB().Update(ethereum.SentryCtx(), func(tx kv.RwTx) error { rawdb.WriteHeadBlockHash(tx, lvh) return nil From d0a8e312ef3e6d8dcdee76d0641eb234b821e542 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 20 May 2026 09:28:43 +0200 Subject: [PATCH 04/45] rpc/jsonrpc: fix head-vs-data inconsistency in bg-commit-aware reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review caught a class of bugs in the FcuBackgroundCommit migrations where the head lookup was overlay-wrapped but the dependent read on the same tx was not. During the ~50ms bg-commit window the head returns N+1 (from the SD overlay) while MDBX is still at N, so the dependent read references a head the rest of its view can't see. - bor_api_impl.go (GetAuthor): pass the same overlayTx to _blockReader.HeaderByNumber. Previously latestBlockNum was N+1 but HeaderByNumber read MDBX for N+1's canonical hash → nil → errUnknownBlock. - eth_block.go (GetBlockTransactionCountByNumber): pass overlayTx to _blockReader.Body. Without this, blockNum can be N+1 while Body returns nil from committed MDBX, dropping the RPC to (nil, nil). - parity_api.go (parity_listStorageKeys): revert to plain tx. There is no good overlay-aware version: the block overlay exposes table writes (TxNums included) but not the SD domain mem batch, so a RangeAsOf over kv.StorageDomain would still bypass the pending storage writes for an overlay-derived block number, yielding an inconsistent view. - trace_filtering.go: revert the toBlock-via-overlay change. filterV3 scans receipts/logs against dbtx; routing only the toBlock through the overlay leaves the upper bound past what the scan can see. Co-Authored-By: Claude Opus 4.7 (1M context) --- rpc/jsonrpc/bor_api_impl.go | 5 +++-- rpc/jsonrpc/eth_block.go | 5 +++-- rpc/jsonrpc/parity_api.go | 8 +++++++- rpc/jsonrpc/trace_filtering.go | 7 +++++-- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/rpc/jsonrpc/bor_api_impl.go b/rpc/jsonrpc/bor_api_impl.go index 6199969e1bf..8aead3b4114 100644 --- a/rpc/jsonrpc/bor_api_impl.go +++ b/rpc/jsonrpc/bor_api_impl.go @@ -103,11 +103,12 @@ func (api *BorImpl) GetAuthor(blockNrOrHash *rpc.BlockNumberOrHash) (accounts.Ad //nolint:nestif if blockNrOrHash == nil { - latestBlockNum, err2 := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) + overlayTx := api.filters.WithOverlay(tx) + latestBlockNum, err2 := rpchelper.GetLatestBlockNumber(overlayTx) if err2 != nil { return accounts.NilAddress, err2 } - header, err = api._blockReader.HeaderByNumber(ctx, tx, latestBlockNum) + header, err = api._blockReader.HeaderByNumber(ctx, overlayTx, latestBlockNum) } else { if blockNr, ok := blockNrOrHash.Number(); ok { header, err = api._blockReader.HeaderByNumber(ctx, tx, uint64(blockNr)) diff --git a/rpc/jsonrpc/eth_block.go b/rpc/jsonrpc/eth_block.go index 0ee555c0373..82f702ef92b 100644 --- a/rpc/jsonrpc/eth_block.go +++ b/rpc/jsonrpc/eth_block.go @@ -376,7 +376,8 @@ func (api *APIImpl) GetBlockTransactionCountByNumber(ctx context.Context, blockN return nil, err } - latestBlockNumber, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) + overlayTx := api.filters.WithOverlay(tx) + latestBlockNumber, err := rpchelper.GetLatestBlockNumber(overlayTx) if err != nil { return nil, err } @@ -385,7 +386,7 @@ func (api *APIImpl) GetBlockTransactionCountByNumber(ctx context.Context, blockN return nil, nil } - body, txCount, err := api._blockReader.Body(ctx, tx, blockHash, blockNum) + body, txCount, err := api._blockReader.Body(ctx, overlayTx, blockHash, blockNum) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/parity_api.go b/rpc/jsonrpc/parity_api.go index c5a65cd928d..93422b71ca8 100644 --- a/rpc/jsonrpc/parity_api.go +++ b/rpc/jsonrpc/parity_api.go @@ -73,7 +73,13 @@ func (api *ParityAPIImpl) ListStorageKeys(ctx context.Context, account common.Ad return nil, errors.New("acc not found") } - bn := rawdb.ReadCurrentBlockNumber(api.filters.WithOverlay(tx)) + // Stay on the committed view: bn, _txNumReader.Min, and the RangeAsOf scan + // over kv.StorageDomain must agree on a single state version. The block + // overlay exposes table writes but not the SD's domain mem batch, so an + // overlay-derived bn would point at a block whose StorageDomain writes + // are not yet visible to RangeAsOf, returning either an error or + // inconsistent storage. + bn := rawdb.ReadCurrentBlockNumber(tx) minTxNum, err := api._txNumReader.Min(ctx, tx, *bn) if err != nil { return nil, err diff --git a/rpc/jsonrpc/trace_filtering.go b/rpc/jsonrpc/trace_filtering.go index 91e98b0e5f8..d83e55b8628 100644 --- a/rpc/jsonrpc/trace_filtering.go +++ b/rpc/jsonrpc/trace_filtering.go @@ -343,8 +343,11 @@ func (api *TraceAPIImpl) Filter(ctx context.Context, req TraceFilterRequest, gas } if req.ToBlock == nil { - overlayTx := api.filters.WithOverlay(dbtx) - headNumber, err := api._blockReader.HeaderNumber(ctx, overlayTx, rawdb.ReadHeadHeaderHash(overlayTx)) + // filterV3 below scans receipts/logs against dbtx; keep toBlock on the + // same view so the upper bound and the scan agree. Routing the head + // lookup through the overlay would let toBlock point at a block whose + // receipts are not yet committed. + headNumber, err := api._blockReader.HeaderNumber(ctx, dbtx, rawdb.ReadHeadHeaderHash(dbtx)) if err != nil { return err } From 79ce7d94ef4d351a7a133ae39741daf65a63d4c5 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 20 May 2026 09:35:10 +0200 Subject: [PATCH 05/45] rpc/jsonrpc: revert head-overlay wrap where dependent reads are plain-tx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep of the remaining migrated callsites for the same head-vs-data inconsistency Copilot flagged in the first review pass: the head lookup was routed through the SD overlay but the downstream reads (log scans, witness/proof builds, setHead, blockWithSenders, binary search) still operate on the plain committed tx, so during the bg-commit window the function references a head its own follow-up reads cannot see. Reverted to plain tx (consistent committed view) in: - eth_call.go (GetProof / CreateAccessList witness paths) - overlay_api.go and erigon_receipts.go and eth_receipts.go (log range upper bounds — getLogsV3 scans against plain tx; eth_getLogs additionally guards on GetLatestExecutedBlockNumber(tx) which would otherwise fire "node is still syncing" for an overlay-derived upper bound) - eth_simulation.go (guard followed by blockWithSenders on plain tx) - debug_api.go (debug_setHead guard; SetHead acts on committed DB) - debug_execution_witness.go (latestBlock gates a branch that reads txnums + seeks commitment on plain tx) - erigon_block.go (GetBlockByTimestamp binary search uses HeaderByNumber on plain tx) Kept overlay-aware where the function uses the result in-memory and does no dependent DB read, or where the dependent read was extended in the previous commit (bor_api_impl.go GetAuthor, eth_block.go GetBlockTransactionCountByNumber): eth_system.go BlockNumber/GasPrice/BaseFee/BlobBaseFee (in-memory) and the GasPriceOracleBackend tx (wrapped once at construction so all b.tx reads are overlay-aware), bor_api_impl.go header-only paths, txpool_api.go Content/ContentFrom, eth_txs.go pool fallback, graphql_api.go GetLatestBlockNumber, trace_filtering.go was already reverted in the previous commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- rpc/jsonrpc/debug_api.go | 4 +++- rpc/jsonrpc/debug_execution_witness.go | 6 ++++-- rpc/jsonrpc/erigon_block.go | 5 ++++- rpc/jsonrpc/erigon_receipts.go | 9 +++++---- rpc/jsonrpc/eth_call.go | 10 ++++++++-- rpc/jsonrpc/eth_receipts.go | 5 ++++- rpc/jsonrpc/eth_simulation.go | 4 +++- rpc/jsonrpc/overlay_api.go | 4 +++- 8 files changed, 34 insertions(+), 13 deletions(-) diff --git a/rpc/jsonrpc/debug_api.go b/rpc/jsonrpc/debug_api.go index 221ba17b515..1c2237ff830 100644 --- a/rpc/jsonrpc/debug_api.go +++ b/rpc/jsonrpc/debug_api.go @@ -105,7 +105,9 @@ func (api *DebugAPIImpl) SetHead(ctx context.Context, number hexutil.Uint64) err } defer tx.Rollback() - currentHead, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) + // Stay on the committed view: api.ethBackend.SetHead operates on the + // committed DB, so the guard must agree with what setHead can see. + currentHead, err := rpchelper.GetLatestBlockNumber(tx) if err != nil { return err } diff --git a/rpc/jsonrpc/debug_execution_witness.go b/rpc/jsonrpc/debug_execution_witness.go index efab22ba941..1c19e50a224 100644 --- a/rpc/jsonrpc/debug_execution_witness.go +++ b/rpc/jsonrpc/debug_execution_witness.go @@ -1112,8 +1112,10 @@ func (api *DebugAPIImpl) buildExpectedPostState( postSdCtx := postDomains.GetCommitmentContext() postSdCtx.SetDeferBranchUpdates(false) - // Set up to read state at current block (after execution) - latestBlock, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) + // Set up to read state at current block (after execution). + // Stay on the committed view: the branch below reads txnums and seeks + // commitment against plain tx, so latestBlock must agree with that view. + latestBlock, err := rpchelper.GetLatestBlockNumber(tx) if err != nil { return nil, nil, fmt.Errorf("failed to get latest block: %w", err) } diff --git a/rpc/jsonrpc/erigon_block.go b/rpc/jsonrpc/erigon_block.go index 2eef4965682..52c68a9dcba 100644 --- a/rpc/jsonrpc/erigon_block.go +++ b/rpc/jsonrpc/erigon_block.go @@ -94,7 +94,10 @@ func (api *ErigonImpl) GetBlockByTimestamp(ctx context.Context, timeStamp rpc.Ti uintTimestamp := timeStamp.TurnIntoUint64() - currentHeader := rawdb.ReadCurrentHeader(api.filters.WithOverlay(tx)) + // Stay on the committed view: the binary search below calls HeaderByNumber + // against plain tx, so the upper bound must agree with what those reads + // can see. + currentHeader := rawdb.ReadCurrentHeader(tx) if currentHeader == nil { return nil, errors.New("current header not found") } diff --git a/rpc/jsonrpc/erigon_receipts.go b/rpc/jsonrpc/erigon_receipts.go index 4f0eab28566..55e91328fed 100644 --- a/rpc/jsonrpc/erigon_receipts.go +++ b/rpc/jsonrpc/erigon_receipts.go @@ -97,8 +97,9 @@ func (api *ErigonImpl) GetLogs(ctx context.Context, crit filters.FilterCriteria) end = header.Number.Uint64() } else { - // Convert the RPC block numbers into internal representations - latest, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) + // Stay on the committed view: getLogsV3 scans logs against the same tx, + // so the latest cap and the scan must agree. + latest, err := rpchelper.GetLatestBlockNumber(tx) if err != nil { return nil, err } @@ -192,8 +193,8 @@ func (api *ErigonImpl) GetLatestLogs(ctx context.Context, crit filters.FilterCri begin = header.Number.Uint64() end = header.Number.Uint64() } else { - // Convert the RPC block numbers into internal representations - latest, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) + // Stay on the committed view: getLogsV3 scans against the same tx. + latest, err := rpchelper.GetLatestBlockNumber(tx) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_call.go b/rpc/jsonrpc/eth_call.go index a337017ab61..ffca547f163 100644 --- a/rpc/jsonrpc/eth_call.go +++ b/rpc/jsonrpc/eth_call.go @@ -467,7 +467,11 @@ func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address co sdCtx := domains.GetCommitmentContext() sdCtx.SetDeferBranchUpdates(false) - latestBlock, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(roTx)) + // Stay on the committed view: the downstream proof computation reads + // txnums, history, and state through roTx + the SD without consulting + // the overlay, so latestBlock must match that view to keep the guard + // consistent. + latestBlock, err := rpchelper.GetLatestBlockNumber(roTx) if err != nil { return nil, err } @@ -674,7 +678,9 @@ func (api *BaseAPI) getWitness(ctx context.Context, db kv.TemporalRoDB, blockNrO return nil, fmt.Errorf("transaction index out of bounds: %d", txIndex) } - latestBlock, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(roTx)) + // Stay on the committed view: regenerateHash / the witness rewind below + // operate against roTx without overlay awareness. + latestBlock, err := rpchelper.GetLatestBlockNumber(roTx) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_receipts.go b/rpc/jsonrpc/eth_receipts.go index fc993ac614f..df366b17ef0 100644 --- a/rpc/jsonrpc/eth_receipts.go +++ b/rpc/jsonrpc/eth_receipts.go @@ -169,7 +169,10 @@ func (api *APIImpl) GetLogs(ctx context.Context, crit filters.FilterCriteria) (t return nil, &rpc.CustomError{Message: errInvalidBlockRange, Code: rpc.ErrCodeInvalidParams} } if end > roaring.MaxUint32 { - latest, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) + // Stay on the committed view: the GetLatestExecutedBlockNumber guard + // below uses plain tx, and getLogsV3 scans logs against the same tx, + // so the latest cap must agree with both. + latest, err := rpchelper.GetLatestBlockNumber(tx) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_simulation.go b/rpc/jsonrpc/eth_simulation.go index 84411bcbccf..884df3ee811 100644 --- a/rpc/jsonrpc/eth_simulation.go +++ b/rpc/jsonrpc/eth_simulation.go @@ -129,7 +129,9 @@ func (api *APIImpl) SimulateV1(ctx context.Context, req SimulationRequest, block if err != nil { return nil, err } - latestBlockNumber, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) + // Stay on the committed view: blockWithSenders below reads via plain tx, + // so the guard must agree with what that read can see. + latestBlockNumber, err := rpchelper.GetLatestBlockNumber(tx) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/overlay_api.go b/rpc/jsonrpc/overlay_api.go index 1995cdd86e7..c47764b6c0c 100644 --- a/rpc/jsonrpc/overlay_api.go +++ b/rpc/jsonrpc/overlay_api.go @@ -628,7 +628,9 @@ func getBeginEnd(ctx context.Context, tx kv.Tx, api *OverlayAPIImpl, crit filter return 0, 0, fmt.Errorf("end (%d) < begin (%d)", end, begin) } if end > roaring.MaxUint32 { - latest, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) + // Stay on the committed view: the caller scans logs/receipts against + // the same tx, so the upper bound must agree with what the scan can see. + latest, err := rpchelper.GetLatestBlockNumber(tx) if err != nil { return 0, 0, err } From b487340d69a941c2c0d67d4cfa0f3d85af0128ca Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 20 May 2026 09:46:00 +0200 Subject: [PATCH 06/45] rpc/jsonrpc: guard nil ReadCurrentBlockNumber in parity_listStorageKeys Pre-existing latent panic: rawdb.ReadCurrentBlockNumber returns *uint64 which is nil when HeadHeaderHash isn't set (fresh DB / pre-head state), and the *bn dereference on the next line would crash the RPC handler. Surface a user-facing error instead. Flagged by Copilot review of the nearby diff in 79ce7d9. Co-Authored-By: Claude Opus 4.7 (1M context) --- rpc/jsonrpc/parity_api.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rpc/jsonrpc/parity_api.go b/rpc/jsonrpc/parity_api.go index 93422b71ca8..cab40fc432b 100644 --- a/rpc/jsonrpc/parity_api.go +++ b/rpc/jsonrpc/parity_api.go @@ -80,6 +80,9 @@ func (api *ParityAPIImpl) ListStorageKeys(ctx context.Context, account common.Ad // are not yet visible to RangeAsOf, returning either an error or // inconsistent storage. bn := rawdb.ReadCurrentBlockNumber(tx) + if bn == nil { + return nil, errors.New("current block number not found") + } minTxNum, err := api._txNumReader.Min(ctx, tx, *bn) if err != nil { return nil, err From 85d168bd66e67e3509f334f3c890b4b346bead5f Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 20 May 2026 15:53:06 +0200 Subject: [PATCH 07/45] rpc/jsonrpc: resolve latest tag on plain tx in log-range scans eth_getLogs, trace_filter.Filter, and overlay_api.getBeginEnd previously resolved the implicit `latest` reference through nil filters (plain tx, returns N-1 during the bg-commit window) but routed user-passed ToBlock/FromBlock="latest" through api.filters (overlay-aware, returns N). The resulting `end > latest` guard would false-positive errBlockRangeIntoFuture for ~50ms after every FCU. Pass nil filters consistently in the explicit-tag resolution so the upper bound, the lower bound, and the underlying getLogsV3/filterV3 scan all agree on the committed view. Pre-existing in foreground-commit mode too, but only observable during the brief commit window when the CL hadn't yet received the FCU response; becomes user-facing with FcuBackgroundCommit=true defaulting on. Co-Authored-By: Claude Opus 4.7 (1M context) --- rpc/jsonrpc/eth_receipts.go | 12 +++++++++--- rpc/jsonrpc/overlay_api.go | 10 +++++++--- rpc/jsonrpc/trace_filtering.go | 12 ++++++------ 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/rpc/jsonrpc/eth_receipts.go b/rpc/jsonrpc/eth_receipts.go index df366b17ef0..124ba839ce1 100644 --- a/rpc/jsonrpc/eth_receipts.go +++ b/rpc/jsonrpc/eth_receipts.go @@ -123,7 +123,13 @@ func (api *APIImpl) GetLogs(ctx context.Context, crit filters.FilterCriteria) (t begin = num end = num } else { - // Convert the RPC block numbers into internal representations + // Resolve block tags on the committed view: getLogsV3 scans logs against + // the plain tx and the begin > latest / end > latest guards below compare + // against `latest` resolved here, so all three must agree. Passing + // api.filters would route "latest"/"safe"/"finalized" through the SD + // overlay during the bg-commit window, leaving begin or end at N while + // `latest` and the scan are still at N-1 — the range check would then + // false-positive errBlockRangeIntoFuture. latest, _, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(rpc.LatestExecutedBlockNumber), tx, api._blockReader, nil) if err != nil { return nil, err @@ -136,7 +142,7 @@ func (api *APIImpl) GetLogs(ctx context.Context, crit filters.FilterCriteria) (t begin = uint64(fromBlock) } else { blockNum := rpc.BlockNumber(fromBlock) - begin, _, _, err = rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(blockNum), tx, api._blockReader, api.filters) + begin, _, _, err = rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(blockNum), tx, api._blockReader, nil) if err != nil { return nil, err } @@ -153,7 +159,7 @@ func (api *APIImpl) GetLogs(ctx context.Context, crit filters.FilterCriteria) (t end = uint64(toBlock) } else { blockNum := rpc.BlockNumber(toBlock) - end, _, _, err = rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(blockNum), tx, api._blockReader, api.filters) + end, _, _, err = rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(blockNum), tx, api._blockReader, nil) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/overlay_api.go b/rpc/jsonrpc/overlay_api.go index c47764b6c0c..f4b8e4ebbea 100644 --- a/rpc/jsonrpc/overlay_api.go +++ b/rpc/jsonrpc/overlay_api.go @@ -589,7 +589,11 @@ func getBeginEnd(ctx context.Context, tx kv.Tx, api *OverlayAPIImpl, crit filter begin = num end = num } else { - // Convert the RPC block numbers into internal representations + // Resolve block tags on the committed view: the caller scans logs/ + // receipts against the same tx (and the MaxUint32 cap below also reads + // `latest` from plain tx), so all references must agree. Passing + // api.filters would route "latest" through the SD overlay during the + // bg-commit window, landing on N while the scan and caps are at N-1. latest, _, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(rpc.LatestExecutedBlockNumber), tx, api._blockReader, nil) if err != nil { return 0, 0, err @@ -602,7 +606,7 @@ func getBeginEnd(ctx context.Context, tx kv.Tx, api *OverlayAPIImpl, crit filter begin = uint64(fromBlock) } else { blockNum := rpc.BlockNumber(fromBlock) - begin, _, _, err = rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(blockNum), tx, api._blockReader, api.filters) + begin, _, _, err = rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(blockNum), tx, api._blockReader, nil) if err != nil { return 0, 0, err } @@ -616,7 +620,7 @@ func getBeginEnd(ctx context.Context, tx kv.Tx, api *OverlayAPIImpl, crit filter end = uint64(toBlock) } else { blockNum := rpc.BlockNumber(toBlock) - end, _, _, err = rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(blockNum), tx, api._blockReader, api.filters) + end, _, _, err = rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(blockNum), tx, api._blockReader, nil) if err != nil { return 0, 0, err } diff --git a/rpc/jsonrpc/trace_filtering.go b/rpc/jsonrpc/trace_filtering.go index d83e55b8628..3b15b15aed4 100644 --- a/rpc/jsonrpc/trace_filtering.go +++ b/rpc/jsonrpc/trace_filtering.go @@ -329,10 +329,14 @@ func (api *TraceAPIImpl) Filter(ctx context.Context, req TraceFilterRequest, gas var fromBlock uint64 var toBlock uint64 var err error + // filterV3 below scans receipts/logs against dbtx; resolve every block tag + // on the same committed view so the upper bound and the scan agree. Routing + // "latest"/"safe"/"finalized" through api.filters would land on a block + // whose receipts are not yet committed during the bg-commit window. if req.FromBlock == nil { fromBlock = 0 } else { - fromBlock, _, _, err = rpchelper.GetBlockNumber(ctx, *req.FromBlock, dbtx, api._blockReader, api.filters) + fromBlock, _, _, err = rpchelper.GetBlockNumber(ctx, *req.FromBlock, dbtx, api._blockReader, nil) if err != nil { if errors.As(err, &rpc.BlockNotFoundErr{}) { stream.WriteEmptyArray() @@ -343,17 +347,13 @@ func (api *TraceAPIImpl) Filter(ctx context.Context, req TraceFilterRequest, gas } if req.ToBlock == nil { - // filterV3 below scans receipts/logs against dbtx; keep toBlock on the - // same view so the upper bound and the scan agree. Routing the head - // lookup through the overlay would let toBlock point at a block whose - // receipts are not yet committed. headNumber, err := api._blockReader.HeaderNumber(ctx, dbtx, rawdb.ReadHeadHeaderHash(dbtx)) if err != nil { return err } toBlock = *headNumber } else { - toBlock, _, _, err = rpchelper.GetBlockNumber(ctx, *req.ToBlock, dbtx, api._blockReader, api.filters) + toBlock, _, _, err = rpchelper.GetBlockNumber(ctx, *req.ToBlock, dbtx, api._blockReader, nil) if err != nil { if errors.As(err, &rpc.BlockNotFoundErr{}) { stream.WriteEmptyArray() From 7c7ac1fced7bd7f473483e81a776390ebacb86e0 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 20 May 2026 15:53:15 +0200 Subject: [PATCH 08/45] rpc/jsonrpc: route block-table head reads through overlay in GetBlockByTimestamp and debug_setHead Two callsites previously reverted to plain tx in 79ce7d9 only touch block tables (no SD-temporal reads), so overlay-aware reads are fully consistent with their dependent reads. - erigon_block.GetBlockByTimestamp: wrap tx once at the top; route ReadCurrentHeader, the inner _blockReader.HeaderByNumber binary-search probe, and the three buildBlockResponse callsites through overlayTx. - debug_setHead: read currentHead through the overlay so debug_setHead(N) during the bg-commit window doesn't false-positive "block N is in the future" for what is effectively a no-op rollback. The SD-temporal reverts (eth_call/getProof, log scans, simulation, commitment seek, parity_listStorageKeys) still need the SD-aware temporal view tracked in #21314. Co-Authored-By: Claude Opus 4.7 (1M context) --- rpc/jsonrpc/debug_api.go | 9 ++++++--- rpc/jsonrpc/erigon_block.go | 18 ++++++++++-------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/rpc/jsonrpc/debug_api.go b/rpc/jsonrpc/debug_api.go index 1c2237ff830..ec81a0a8d25 100644 --- a/rpc/jsonrpc/debug_api.go +++ b/rpc/jsonrpc/debug_api.go @@ -105,9 +105,12 @@ func (api *DebugAPIImpl) SetHead(ctx context.Context, number hexutil.Uint64) err } defer tx.Rollback() - // Stay on the committed view: api.ethBackend.SetHead operates on the - // committed DB, so the guard must agree with what setHead can see. - currentHead, err := rpchelper.GetLatestBlockNumber(tx) + // Read head through the overlay so a no-op debug_setHead(N) issued during + // the bg-commit window (when the overlay says N but MDBX is still at N-1) + // doesn't false-positive "block N is in the future". The guard is a pure + // number comparison and SetHead itself runs through ethBackend on the + // committed DB after the bg goroutine releases the FCU semaphore. + currentHead, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) if err != nil { return err } diff --git a/rpc/jsonrpc/erigon_block.go b/rpc/jsonrpc/erigon_block.go index 52c68a9dcba..4e313972855 100644 --- a/rpc/jsonrpc/erigon_block.go +++ b/rpc/jsonrpc/erigon_block.go @@ -94,10 +94,12 @@ func (api *ErigonImpl) GetBlockByTimestamp(ctx context.Context, timeStamp rpc.Ti uintTimestamp := timeStamp.TurnIntoUint64() - // Stay on the committed view: the binary search below calls HeaderByNumber - // against plain tx, so the upper bound must agree with what those reads - // can see. - currentHeader := rawdb.ReadCurrentHeader(tx) + // Route every block-table read (head, binary search, final block lookup) + // through the overlay so all bounds and reads agree on a single view + // during the bg-commit window. Only block tables are consulted — no + // SD-temporal reads — so overlay-aware is fully consistent here. + overlayTx := api.filters.WithOverlay(tx) + currentHeader := rawdb.ReadCurrentHeader(overlayTx) if currentHeader == nil { return nil, errors.New("current header not found") } @@ -116,7 +118,7 @@ func (api *ErigonImpl) GetBlockByTimestamp(ctx context.Context, timeStamp rpc.Ti firstHeaderTime := firstHeader.Time if currentHeaderTime <= uintTimestamp { - blockResponse, err := buildBlockResponse(ctx, api._blockReader, tx, highestNumber, fullTx) + blockResponse, err := buildBlockResponse(ctx, api._blockReader, overlayTx, highestNumber, fullTx) if err != nil { return nil, err } @@ -125,7 +127,7 @@ func (api *ErigonImpl) GetBlockByTimestamp(ctx context.Context, timeStamp rpc.Ti } if firstHeaderTime >= uintTimestamp { - blockResponse, err := buildBlockResponse(ctx, api._blockReader, tx, 0, fullTx) + blockResponse, err := buildBlockResponse(ctx, api._blockReader, overlayTx, 0, fullTx) if err != nil { return nil, err } @@ -134,7 +136,7 @@ func (api *ErigonImpl) GetBlockByTimestamp(ctx context.Context, timeStamp rpc.Ti } blockNum := sort.Search(int(currentHeader.Number.Uint64()), func(blockNum int) bool { - currentHeader, err := api._blockReader.HeaderByNumber(ctx, tx, uint64(blockNum)) + currentHeader, err := api._blockReader.HeaderByNumber(ctx, overlayTx, uint64(blockNum)) if err != nil { return false } @@ -173,7 +175,7 @@ func (api *ErigonImpl) GetBlockByTimestamp(ctx context.Context, timeStamp rpc.Ti if err != nil { return nil, err } - response, err := buildBlockResponse(ctx, api._blockReader, tx, uint64(blockNum), fullTx) + response, err := buildBlockResponse(ctx, api._blockReader, overlayTx, uint64(blockNum), fullTx) if err != nil { return nil, err } From 1688f7961b64d9cf0cd9ff54aa7f3f5537fdd16f Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 20 May 2026 15:53:32 +0200 Subject: [PATCH 09/45] node/ethconfig, rpc/jsonrpc, cmd/utils, docs: clarify FcuBackgroundCommit semantics and known limitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the trade-offs of routing head reads through the SD block overlay during the bg-commit window: - node/ethconfig/config.go: expand the FcuBackgroundCommit doc block to cover the FCU semaphore sequencing, embedded vs. remote rpcdaemon semantics, and the eth_call(latest) header-vs-state lag (~50ms). Link to #21314 for the proper SD-aware view follow-up. - rpc/jsonrpc/eth_call.go: note that BlockOverlayTemporalTx wraps table reads but delegates temporal methods to roTx; link to #21314. - rpc/jsonrpc/eth_simulation.go: comment was wrong — blockWithSenders auto-wraps. The real reason latest stays on plain tx is NewSharedDomains ties the simulator to the plain tx's domain state. - rpc/jsonrpc/eth_system.go: explain why GasPriceOracleBackend.Fork opens a non-overlay-wrapped tx (downstream helpers re-wrap internally). - execution/execmodule/exec_module_test.go: replace stale "subsequent blocks may fail validation" comment on TestNotificationDispatchBackgroundCommit — the semaphore serializes FCUs so N+1 always reads N's committed state. - cmd/utils/flags.go + docs (configuring-erigon.mdx, llms-full.txt): update --fcu.background.commit usage and default; flag a concise rpcdaemon caveat in the description. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/utils/flags.go | 2 +- .../fundamentals/configuring-erigon/index.mdx | 4 +-- docs/site/static/llms-full.txt | 4 +-- execution/execmodule/exec_module_test.go | 10 +++--- llms-full.txt | 4 +-- node/ethconfig/config.go | 31 +++++++++++++++---- rpc/jsonrpc/eth_call.go | 13 ++++++-- rpc/jsonrpc/eth_simulation.go | 6 ++-- rpc/jsonrpc/eth_system.go | 6 ++++ 9 files changed, 59 insertions(+), 21 deletions(-) diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go index b807a8f3947..104c5810402 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -1133,7 +1133,7 @@ var ( } FcuBackgroundCommitFlag = cli.BoolFlag{ Name: "fcu.background.commit", - Usage: "Enables background flush and commit", + Usage: "Return FCU response before MDBX flush+commit lands (commit runs in background; remote rpcdaemon 'latest' lags ~50ms, stays consistent)", Value: ethconfig.Defaults.FcuBackgroundCommit, } MCPDisableFlag = cli.BoolFlag{ diff --git a/docs/site/docs/fundamentals/configuring-erigon/index.mdx b/docs/site/docs/fundamentals/configuring-erigon/index.mdx index 283a0f4d6ed..dc4553fc9fe 100644 --- a/docs/site/docs/fundamentals/configuring-erigon/index.mdx +++ b/docs/site/docs/fundamentals/configuring-erigon/index.mdx @@ -409,8 +409,8 @@ Flags for configuring Fork Choice Update behavior. * Default: `1s` * `--fcu.background.prune`: Enables background pruning after FCU. * Default: `true` -* `--fcu.background.commit`: Enables background flush and commit after FCU. - * Default: `false` +* `--fcu.background.commit`: Returns the FCU response before MDBX flush+commit lands (commit runs in background; remote `rpcdaemon` `latest` lags ~50ms, stays consistent). + * Default: `true` ### Caplin (Consensus Layer) diff --git a/docs/site/static/llms-full.txt b/docs/site/static/llms-full.txt index 1b6bc25de83..2c5c5300fb0 100644 --- a/docs/site/static/llms-full.txt +++ b/docs/site/static/llms-full.txt @@ -3204,8 +3204,8 @@ Flags for configuring Fork Choice Update behavior. * Default: `1s` * `--fcu.background.prune`: Enables background pruning after FCU. * Default: `true` -* `--fcu.background.commit`: Enables background flush and commit after FCU. - * Default: `false` +* `--fcu.background.commit`: Returns the FCU response before MDBX flush+commit lands (commit runs in background; remote `rpcdaemon` `latest` lags ~50ms, stays consistent). + * Default: `true` ### Caplin (Consensus Layer) diff --git a/execution/execmodule/exec_module_test.go b/execution/execmodule/exec_module_test.go index e69d32a2ca4..9a79a7dcbd5 100644 --- a/execution/execmodule/exec_module_test.go +++ b/execution/execmodule/exec_module_test.go @@ -1030,10 +1030,12 @@ func TestNotificationDispatchForegroundCommit(t *testing.T) { // commit enabled, notifications are still dispatched before FCU returns, // even though the DB commit happens asynchronously. // -// Note: with background commit, subsequent blocks may fail validation -// because the DB state hasn't caught up yet (the commit is async). This -// test only processes the genesis → block 1 transition to verify that -// notification dispatch works correctly in the background commit path. +// Successive FCUs are correctly serialized via the ExecModule semaphore +// (see updateForkChoice / runPostForkchoice): the bg goroutine releases +// the semaphore only after Flush+Commit, so FCU N+1 always reads the +// committed state of FCU N. This test exercises one genesis → block 1 +// transition; multi-block coverage lives in TestNotificationDispatchForegroundCommit +// and the integration suites. func TestNotificationDispatchBackgroundCommit(t *testing.T) { m := execmoduletester.New(t, execmoduletester.WithFcuBackgroundCommit()) diff --git a/llms-full.txt b/llms-full.txt index 1b6bc25de83..2c5c5300fb0 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -3204,8 +3204,8 @@ Flags for configuring Fork Choice Update behavior. * Default: `1s` * `--fcu.background.prune`: Enables background pruning after FCU. * Default: `true` -* `--fcu.background.commit`: Enables background flush and commit after FCU. - * Default: `false` +* `--fcu.background.commit`: Returns the FCU response before MDBX flush+commit lands (commit runs in background; remote `rpcdaemon` `latest` lags ~50ms, stays consistent). + * Default: `true` ### Caplin (Consensus Layer) diff --git a/node/ethconfig/config.go b/node/ethconfig/config.go index 07a25ee3034..64849b663a2 100644 --- a/node/ethconfig/config.go +++ b/node/ethconfig/config.go @@ -115,12 +115,31 @@ var Defaults = Config{ FcuBackgroundPrune: true, // FcuBackgroundCommit lets the FCU response return to the consensus client // before MDBX commit lands. Notifications are dispatched pre-commit from the - // SharedDomains overlay (see notification_dispatcher.go), and embedded RPC - // reads consult the overlay via filters.LatestSD()/WithOverlay (see - // rpc/rpchelper/filters.go). Remote rpcdaemon's kvcache.Coherent receives - // pre-commit StateChanges and correctly serves the matching state-version - // root while the remote tx still observes the pre-commit MDBX state — a - // ~50ms window of consistent (lagging) reads, not divergent state. + // SharedDomains overlay (see execution/execmodule/notification_dispatcher.go). + // Successive FCUs are serialized through the ExecModule semaphore so FCU N+1 + // always reads FCU N's committed state. + // + // Embedded rpcdaemon: head-sensitive paths whose dependent reads are also + // overlay-backed (canonical hashes, headers, bodies, stage progress, TxNums) + // consult filters.LatestSD()/WithOverlay (see rpc/rpchelper/filters.go), so + // they see the new head without waiting for fsync. Paths whose dependent + // reads use SD-temporal data (eth_call, getProof, witness, simulation, log + // range scans) intentionally stay on the committed plain tx to avoid a + // head-vs-state divergence. + // + // Remote rpcdaemon: kvcache.Coherent receives pre-commit StateChanges and + // the matching root is keyed by PlainStateVersion, which is bumped atomically + // inside the commit batch — remote txs observe a consistent (lagging) state, + // not a divergent one. + // + // Known limitation: during the ~50ms window between FCU response and MDBX + // commit, "latest"-anchored *state* reads (eth_call(latest), eth_getBalance, + // eth_getStorageAt, eth_getCode) return block N's header but evaluate against + // block N-1's state — the SD's domain mem batch is not exposed by the block + // overlay. A proper SD-aware temporal view that chains SD.mem → block + // overlay → committed MDBX is tracked in + // https://github.com/erigontech/erigon/issues/21314. The trade-off here is + // bounded staleness (~50ms), not corruption. FcuBackgroundCommit: true, ExperimentalBAL: false, } diff --git a/rpc/jsonrpc/eth_call.go b/rpc/jsonrpc/eth_call.go index ffca547f163..b3d44e30a98 100644 --- a/rpc/jsonrpc/eth_call.go +++ b/rpc/jsonrpc/eth_call.go @@ -73,8 +73,17 @@ func (api *APIImpl) Call(ctx context.Context, args ethapi2.CallArgs, requestedBl } defer roTx.Rollback() - // Use the block overlay if available — reads uncommitted data from the - // pre-commit overlay so consumers don't need to wait for DB commit. + // Use the block overlay if available so block-tag resolution and header/body + // reads see uncommitted data from the pre-commit overlay. + // + // Note: BlockOverlayTemporalTx wraps *table* reads (canonical hashes, + // headers, stage progress) but its temporal methods (GetLatest, GetAsOf, + // RangeAsOf, HistorySeek) delegate to the underlying roTx — the SD's domain + // mem batch is not exposed. During the bg-commit window (FcuBackgroundCommit), + // "latest" resolves to block N via the overlay tables, but state reads + // evaluate against block N-1's committed domain state. This is a bounded + // staleness (~50ms), not a divergence; the proper SD-aware temporal view is + // tracked in https://github.com/erigontech/erigon/issues/21314. var tx kv.TemporalTx = roTx if api.filters != nil { if sd := api.filters.LatestSD(); sd != nil { diff --git a/rpc/jsonrpc/eth_simulation.go b/rpc/jsonrpc/eth_simulation.go index 884df3ee811..e59fd3d4824 100644 --- a/rpc/jsonrpc/eth_simulation.go +++ b/rpc/jsonrpc/eth_simulation.go @@ -129,8 +129,10 @@ func (api *APIImpl) SimulateV1(ctx context.Context, req SimulationRequest, block if err != nil { return nil, err } - // Stay on the committed view: blockWithSenders below reads via plain tx, - // so the guard must agree with what that read can see. + // Stay on the committed view: NewSharedDomains below builds the simulator + // on plain tx, so state reads only see committed domain data (the SD's mem + // batch is not exposed by the block overlay). An overlay-aware latest could + // land at N while the simulator can only reach state at N-1. latestBlockNumber, err := rpchelper.GetLatestBlockNumber(tx) if err != nil { return nil, err diff --git a/rpc/jsonrpc/eth_system.go b/rpc/jsonrpc/eth_system.go index 105932f2740..cca749a0cc8 100644 --- a/rpc/jsonrpc/eth_system.go +++ b/rpc/jsonrpc/eth_system.go @@ -368,6 +368,12 @@ func (b *GasPriceOracleBackend) Fork(ctx context.Context) (gasprice.OracleBacken if b.db == nil { return nil, nil, nil // Fork not supported; caller falls back to sequential } + // The forked tx is NOT overlay-wrapped — `head` was already resolved on the + // main backend (overlay-aware via api.filters.WithTemporalOverlay), and the + // helpers the forked backend dispatches to (BaseAPI.headerByNumber, + // blockByNumberWithSenders, blockWithSenders) re-wrap internally so reads + // of head=N during the bg-commit window still see overlay-backed data. Any + // future caller that bypasses those helpers must wrap explicitly. tx, err := b.db.BeginTemporalRo(ctx) //nolint:gocritic if err != nil { return nil, nil, err From ab060abbc58cd4351fe605be07fab115b02948e4 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 20 May 2026 16:14:43 +0200 Subject: [PATCH 10/45] db/kv/membatchwithdb, db/state/execctx, rpc/rpchelper: document overlay safe-close invariant and assert memStore backing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RPC readers acquire views via Filters.WithOverlay / WithTemporalOverlay / SharedDomains.BlockOverlayTemporalTx that share memTx with the published SD's BlockOverlay. The FCU bg-commit goroutine closes that SD while those readers are still iterating, and the only thing keeping the pattern safe today is that the BlockOverlay is backed by membatchwithdb.NewMemoryBatch — a pure-Go memStore whose Rollback/Close are no-ops on the in-memory data (memory_store.go), and that views hold their own caller-supplied backing tx. Both properties were load-bearing but undocumented; flagged in the FcuBackgroundCommit=true review as a latent risk. Make the invariant explicit: - MemoryMutation.Rollback (memory_mutation.go): comment explaining the no-op semantics for memStore-backed batches and the consequence for concurrent read views. - MemoryMutation.NewReadView (memory_mutation.go): concurrency note pointing to newReadViewMut. - MemoryMutation.newReadViewMut (memory_mutation.go): runtime panic if m.memTx is not *memStore. Catches the case where someone swaps NewMemoryBatch for NewMemoryBatchMDBX (which DOES invalidate cursors on Rollback) and forgets that the safe-concurrent-close property vanishes. The panic message points at the required follow-up (refcount/drain) for any MDBX-backed overlay with concurrent readers. - SharedDomains.Close (domain_shared.go): comment walking through each of sd.mem / sd.blockOverlay / sd.sdCtx and why concurrent RPC views remain safe under each close path. - Filters.WithOverlay / WithTemporalOverlay (filters.go): public-API documentation of the safe-concurrent-close property and the standard caller-tx-lifecycle constraint that still applies. No behaviour change for the supported (memStore-backed) path; the panic fires only if an unsupported backing store is wired in. Co-Authored-By: Claude Opus 4.7 (1M context) --- db/kv/membatchwithdb/memory_mutation.go | 35 +++++++++++++++++++++++++ db/state/execctx/domain_shared.go | 21 +++++++++++++++ rpc/rpchelper/filters.go | 20 ++++++++++++-- 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/db/kv/membatchwithdb/memory_mutation.go b/db/kv/membatchwithdb/memory_mutation.go index 44334bc1575..11d3128d056 100644 --- a/db/kv/membatchwithdb/memory_mutation.go +++ b/db/kv/membatchwithdb/memory_mutation.go @@ -562,6 +562,25 @@ func (m *MemoryMutation) Commit() error { return nil } +// Rollback releases this mutation's local cursor cache and forwards Rollback +// to the backing in-memory tx / db. +// +// Concurrency invariant (load-bearing — see also Filters.WithOverlay): for a +// MemoryMutation created via NewMemoryBatch (the pure-Go memStore backing +// used by SharedDomains.blockOverlay), memTx.Rollback and memDb.Close are +// no-ops on the in-memory data — see memory_store.go. That is what makes it +// safe for the FCU bg-commit goroutine to call Close on the published +// BlockOverlay while concurrent RPC readers are still iterating views +// obtained via NewReadView / NewTemporalReadView: the views share memTx but +// the data behind it is not destroyed. Statefulness affecting the parent's +// own statelessCursors map below does not propagate to views (views inherit +// an independent lazy cursor cache via newReadViewMut). +// +// If this MemoryMutation is ever switched to NewMemoryBatchMDBX (real MDBX +// backing, where Rollback DOES invalidate cursors), the bg-commit close + +// concurrent-RPC-reader pattern becomes unsafe and refcounting/drain logic +// is required. newReadViewMut enforces this at runtime via the *memStore +// type-assertion. func (m *MemoryMutation) Rollback() { m.memTx.Rollback() m.memDb.Close() @@ -970,6 +989,12 @@ func (m *MemoryMutation) Unwind(ctx context.Context, txNumUnwindTo uint64, chang // The returned kv.TemporalTx only exposes read methods. Callers cannot write // to the overlay through this view. The caller must not Close the returned // view (it doesn't own the memDb). +// +// Concurrency: the view remains safe to read even if the parent's Close / +// Rollback runs concurrently (e.g. the FCU bg-commit goroutine closing the +// published BlockOverlay) — but only because the parent is memStore-backed, +// whose Rollback/Close are no-ops on the in-memory data. See newReadViewMut +// for the runtime assertion enforcing that invariant. func (m *MemoryMutation) NewReadView(tx kv.Tx) kv.TemporalTx { return m.newReadViewMut(tx) } @@ -977,6 +1002,16 @@ func (m *MemoryMutation) NewReadView(tx kv.Tx) kv.TemporalTx { // 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 { + // Enforce the safe-concurrent-close invariant documented on Rollback and + // on NewReadView: read views remain safe under a concurrent parent Close + // only because the pure-Go memStore's Rollback/Close are no-ops. An + // MDBX-backed memTx (NewMemoryBatchMDBX) would invalidate cursors on + // Rollback and break readers mid-iteration. If you need MDBX-backed + // overlays with concurrent readers, add a refcount/drain step at the + // parent's Close before relaxing this assertion. + if _, ok := m.memTx.(*memStore); !ok { + panic(fmt.Sprintf("MemoryMutation.newReadViewMut: shared-tx read views require pure-Go memStore backing; got %T (use NewMemoryBatch, not NewMemoryBatchMDBX)", m.memTx)) + } var dbTx kv.TemporalTx if t, ok := tx.(kv.TemporalTx); ok { dbTx = t diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 16868883e4b..bd7a63f91c1 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -624,6 +624,27 @@ func (sd *SharedDomains) IteratePrefix(domain kv.Domain, prefix []byte, roTx kv. return sd.mem.IteratePrefix(domain, prefix, roTx, it) } +// Close releases this SD's in-memory state. Idempotent. +// +// Concurrency with RPC readers: when the FCU bg-commit goroutine calls Close +// on the published SD (forkchoice.go bg path), concurrent RPC readers that +// obtained a view via Filters.WithOverlay / WithTemporalOverlay / +// SharedDomains.BlockOverlayTemporalTx remain safe. Why: +// - sd.mem (TemporalMemBatch) holds domain writes that are never exposed +// to RPC views — the temporal methods on the block overlay's views +// delegate to the caller's own tx, not to sd.mem. Closing sd.mem does +// not reach into any view. +// - sd.blockOverlay (MemoryMutation on memStore) has no-op Rollback/Close +// for its in-memory data — see MemoryMutation.Rollback. Views' shared +// memTx therefore keeps serving reads after the parent's Close. +// - sd.sdCtx is the commitment context, internal to this SD; views don't +// reference it. +// +// In other words: PublishOverlay(nil) before this Close blocks NEW readers +// from acquiring the overlay, and the no-op-on-data semantics keeps already- +// acquired views readable until the caller's own tx is rolled back. Do not +// change BlockOverlay's backing store to one that destroys data on close +// without adding a refcount/drain step here (see newReadViewMut). func (sd *SharedDomains) Close() { if sd.sdCtx == nil { //idempotency return diff --git a/rpc/rpchelper/filters.go b/rpc/rpchelper/filters.go index 101d2e6277a..f7f8a222788 100644 --- a/rpc/rpchelper/filters.go +++ b/rpc/rpchelper/filters.go @@ -891,9 +891,23 @@ func (ff *Filters) LatestSD() *execctx.SharedDomains { // WithOverlay returns a read view backed by the latest block overlay if one // is available, otherwise returns the given tx unchanged. The read view uses -// the overlay's in-memory data for table lookups, falling back to the caller's tx -// for data not in the overlay. +// the overlay's in-memory data for table lookups, falling back to the caller's +// tx for data not in the overlay. // Safe to call on a nil receiver. +// +// Concurrency: the returned view stays safe to use even if the FCU bg-commit +// goroutine concurrently closes the published SD (the typical lifecycle — +// PublishOverlay(SD) → reader acquires view → reader uses view → bg goroutine +// PublishOverlay(nil) + SD.Close → reader continues for some time → caller's +// tx.Rollback). This is load-bearing on (a) the BlockOverlay being backed by +// a pure-Go memStore whose Rollback/Close are no-ops on the in-memory data +// (see MemoryMutation.Rollback and memory_store.go) and (b) the view holding +// its own backing tx (the caller's tx, not the SD's). Both invariants are +// asserted at view-construction time in MemoryMutation.newReadViewMut. Do +// not relax either without adding refcount/drain logic to SharedDomains.Close. +// +// The standard tx lifecycle still applies: views must not be used after the +// CALLER'S tx is rolled back. func (ff *Filters) WithOverlay(tx kv.Tx) kv.Tx { if ff == nil { return tx @@ -910,6 +924,8 @@ func (ff *Filters) WithOverlay(tx kv.Tx) kv.Tx { // WithTemporalOverlay is like WithOverlay but returns kv.TemporalTx directly, // avoiding repeated type assertions at callsites that need temporal access. +// The same concurrent-close safety property as WithOverlay applies (see the +// concurrency note there). func (ff *Filters) WithTemporalOverlay(tx kv.TemporalTx) kv.TemporalTx { if ff == nil { return tx From 51a2426a914ae02cbc0dec360f2738c68a99d2c3 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 20 May 2026 16:37:16 +0200 Subject: [PATCH 11/45] rpc/rpchelper: tolerate nil filters in _GetBlockNumber pending branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 85d168bd66 routed eth_getLogs / erigon_getLogs / overlay.* / trace_filter explicit-tag resolution through rpchelper.GetBlockNumber with nil filters, so the upper bound stays on the same committed view that the underlying scan uses. But _GetBlockNumber's PendingBlockNumber branch dereferenced filters.LastPendingBlock() without a nil check — a latent crash that was unreachable while every caller passed non-nil filters. The pre-existing nil-filters call at eth_receipts.go:127 used the LatestExecutedBlockNumber tag and never hit this branch; my new nil-filters calls forward user-supplied tags, so a user passing FromBlock/ToBlock="pending" would now panic. Fall back to plainStateBlockNumber when filters is nil — same as the existing path when filters is set but holds no pending block — which is the right semantics for the log-range scan callsites (they can only see committed data anyway). Caught by the Copilot reviewer on the latest PR push (29db691... review of ab060abbc5). Co-Authored-By: Claude Opus 4.7 (1M context) --- rpc/rpchelper/helper.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/rpc/rpchelper/helper.go b/rpc/rpchelper/helper.go index 0811d7d36e6..82e600759cc 100644 --- a/rpc/rpchelper/helper.go +++ b/rpc/rpchelper/helper.go @@ -120,12 +120,17 @@ func _GetBlockNumber(ctx context.Context, requireCanonical bool, blockNrOrHash r return 0, common.Hash{}, false, false, err } case rpc.PendingBlockNumber: - pendingBlock := filters.LastPendingBlock() - if pendingBlock == nil { - blockNumber = plainStateBlockNumber - } else { - return pendingBlock.NumberU64(), pendingBlock.Hash(), false, true, nil + // filters may be nil here: callers that intentionally disable + // overlay-aware resolution (log-range scans against committed tx) + // pass nil. Treat that as "no pending block known" and fall back + // to plainStateBlockNumber — same as when filters is set but has + // no pending block. + if filters != nil { + if pendingBlock := filters.LastPendingBlock(); pendingBlock != nil { + return pendingBlock.NumberU64(), pendingBlock.Hash(), false, true, nil + } } + blockNumber = plainStateBlockNumber case rpc.LatestExecutedBlockNumber: blockNumber = plainStateBlockNumber default: From cbc2a70dad23d591c8fc64e2193133a45e18dc90 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Mon, 6 Jul 2026 13:47:25 +0200 Subject: [PATCH 12/45] rpc/jsonrpc: drop eth_system overlay wrap superseded by #22006 Wrapping the whole gas-oracle backend in the overlay makes resolveBlockRange land on the pre-commit head N while GetReceiptsGasUsed reads ReceiptDomain through the committed tx, corrupting the newest eth_feeHistory bucket. Main has since merged #22006, which keeps the oracle on the committed tx and only wraps the final head/base-fee read; carry no eth_system changes here so that shape survives the merge. --- rpc/jsonrpc/eth_system.go | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/rpc/jsonrpc/eth_system.go b/rpc/jsonrpc/eth_system.go index cca749a0cc8..cc8cdd15b3a 100644 --- a/rpc/jsonrpc/eth_system.go +++ b/rpc/jsonrpc/eth_system.go @@ -121,8 +121,7 @@ func (api *APIImpl) GasPrice(ctx context.Context) (*hexutil.Big, error) { return nil, err } defer tx.Rollback() - overlayTx := api.filters.WithTemporalOverlay(tx) - oracle := gasprice.NewOracle(NewGasPriceOracleBackend(api.db, overlayTx, api.BaseAPI), ethconfig.Defaults.GPO, api.gasCache, nil, api.logger.New("app", "gasPriceOracle")) + oracle := gasprice.NewOracle(NewGasPriceOracleBackend(api.db, tx, api.BaseAPI), ethconfig.Defaults.GPO, api.gasCache, nil, api.logger.New("app", "gasPriceOracle")) tipcap, err := oracle.SuggestTipCap(ctx) gasResult := uint256.NewInt(0) @@ -130,7 +129,7 @@ func (api *APIImpl) GasPrice(ctx context.Context) (*hexutil.Big, error) { if err != nil { return nil, err } - if head := rawdb.ReadCurrentHeader(overlayTx); head != nil && head.BaseFee != nil { + if head := rawdb.ReadCurrentHeader(tx); head != nil && head.BaseFee != nil { gasResult.Add(tipcap, head.BaseFee) } @@ -144,7 +143,7 @@ func (api *APIImpl) MaxPriorityFeePerGas(ctx context.Context) (*hexutil.Big, err return nil, err } defer tx.Rollback() - oracle := gasprice.NewOracle(NewGasPriceOracleBackend(api.db, api.filters.WithTemporalOverlay(tx), api.BaseAPI), ethconfig.Defaults.GPO, api.gasCache, nil, api.logger.New("app", "gasPriceOracle")) + oracle := gasprice.NewOracle(NewGasPriceOracleBackend(api.db, tx, api.BaseAPI), ethconfig.Defaults.GPO, api.gasCache, nil, api.logger.New("app", "gasPriceOracle")) tipcap, err := oracle.SuggestTipCap(ctx) if err != nil { return nil, err @@ -167,7 +166,7 @@ func (api *APIImpl) FeeHistory(ctx context.Context, blockCount rpc.DecimalOrHex, return nil, err } defer tx.Rollback() - oracle := gasprice.NewOracle(NewGasPriceOracleBackend(api.db, api.filters.WithTemporalOverlay(tx), api.BaseAPI), ethconfig.Defaults.GPO, api.gasCache, api.feeHistoryCache, api.logger.New("app", "gasPriceOracle")) + oracle := gasprice.NewOracle(NewGasPriceOracleBackend(api.db, tx, api.BaseAPI), ethconfig.Defaults.GPO, api.gasCache, api.feeHistoryCache, api.logger.New("app", "gasPriceOracle")) oldest, reward, baseFee, gasUsed, blobBaseFee, blobGasUsedRatio, err := oracle.FeeHistory(ctx, int(blockCount), lastBlock, rewardPercentiles) if err != nil { @@ -212,7 +211,7 @@ func (api *APIImpl) BlobBaseFee(ctx context.Context) (*hexutil.Big, error) { return nil, err } defer tx.Rollback() - header := rawdb.ReadCurrentHeader(api.filters.WithOverlay(tx)) + header := rawdb.ReadCurrentHeader(tx) if header == nil || header.ExcessBlobGas == nil { return (*hexutil.Big)(common.Big0), nil } @@ -239,7 +238,7 @@ func (api *APIImpl) BaseFee(ctx context.Context) (*hexutil.Big, error) { return nil, err } defer tx.Rollback() - header := rawdb.ReadCurrentHeader(api.filters.WithOverlay(tx)) + header := rawdb.ReadCurrentHeader(tx) if header == nil { return (*hexutil.Big)(common.Big0), nil } @@ -368,12 +367,6 @@ func (b *GasPriceOracleBackend) Fork(ctx context.Context) (gasprice.OracleBacken if b.db == nil { return nil, nil, nil // Fork not supported; caller falls back to sequential } - // The forked tx is NOT overlay-wrapped — `head` was already resolved on the - // main backend (overlay-aware via api.filters.WithTemporalOverlay), and the - // helpers the forked backend dispatches to (BaseAPI.headerByNumber, - // blockByNumberWithSenders, blockWithSenders) re-wrap internally so reads - // of head=N during the bg-commit window still see overlay-backed data. Any - // future caller that bypasses those helpers must wrap explicitly. tx, err := b.db.BeginTemporalRo(ctx) //nolint:gocritic if err != nil { return nil, nil, err From 6b4b6a6dbc7f637e1edc5f57f525fe94f885d706 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Mon, 6 Jul 2026 13:47:25 +0200 Subject: [PATCH 13/45] cmd/rpcdaemon, docs: default to the version-keyed Coherent state cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State-change batches are dispatched before the EL's MDBX commit lands. The non-versioned SimpleCache applies them immediately, so a standalone daemon (remote or --datadir) could serve block-N account data against a committed block-N-1 head — a head-vs-state divergence, not just lag. Default --state.cache to 128MB, which selects the Coherent cache (entries keyed by PlainStateVersion), and honor the flag in the with-datadir path instead of hard-coding SimpleCache. This is the 'revive Coherent cache for rpcdaemon' precondition for enabling FcuBackgroundCommit by default. The embedded daemon keeps SimpleCache: its head resolution is overlay-aware, so the pre-commit values align with the head it reports. --- cmd/rpcdaemon/cli/config.go | 17 ++++++++++------- .../docs/fundamentals/modules/rpc-daemon.md | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/cmd/rpcdaemon/cli/config.go b/cmd/rpcdaemon/cli/config.go index eafe14097e7..5633b292fb0 100644 --- a/cmd/rpcdaemon/cli/config.go +++ b/cmd/rpcdaemon/cli/config.go @@ -137,7 +137,7 @@ func RootCommand() (*cobra.Command, *httpcfg.HttpCfg) { rootCmd.PersistentFlags().BoolVar(&cfg.GethCompatibility, "rpc.gethcompat", false, "Enables Geth-compatible storage iteration order for debug_storageRangeAt (sorted by keccak256 hash). Disabled by default for performance.") rootCmd.PersistentFlags().StringVar(&cfg.TxPoolApiAddr, "txpool.api.addr", "", "txpool api network address, for example: 127.0.0.1:9090 (default: use value of --private.api.addr)") - rootCmd.PersistentFlags().StringVar(&stateCacheStr, "state.cache", "0MB", "Amount of data to store in StateCache (enabled if no --datadir set). Set 0 to disable StateCache. Defaults to 0MB RAM") + rootCmd.PersistentFlags().StringVar(&stateCacheStr, "state.cache", "128MB", "Amount of data to store in the version-keyed StateCache. Set 0 to fall back to a non-versioned last-block cache") rootCmd.PersistentFlags().BoolVar(&cfg.GRPCServerEnabled, "grpc", false, "Enable GRPC server") rootCmd.PersistentFlags().StringVar(&cfg.GRPCListenAddress, "grpc.addr", nodecfg.DefaultGRPCHost, "GRPC server listening interface") rootCmd.PersistentFlags().IntVar(&cfg.GRPCPort, "grpc.port", nodecfg.DefaultGRPCPort, "GRPC server listening port") @@ -525,7 +525,6 @@ func RemoteServices(ctx context.Context, cfg *httpcfg.HttpCfg, logger log.Logger if err != nil { return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err } - stateCache = kvcache.NewSimple() } // If DB can't be configured - used PrivateApiAddr as remote DB if db == nil { @@ -533,14 +532,18 @@ func RemoteServices(ctx context.Context, cfg *httpcfg.HttpCfg, logger log.Logger } if !cfg.WithDatadir { - if cfg.StateCache.CacheSize > 0 { - stateCache = kvcache.New(cfg.StateCache) - } else { - stateCache = kvcache.NewSimple() - } logger.Info("if you run RPCDaemon on same machine with Erigon add --datadir option") } + // State-change batches arrive before the EL commits them; the Coherent + // cache keys entries by PlainStateVersion so reads stay consistent with + // this daemon's committed view, while SimpleCache serves them immediately. + if cfg.StateCache.CacheSize > 0 { + stateCache = kvcache.New(cfg.StateCache) + } else { + stateCache = kvcache.NewSimple() + } + subscribeToStateChangesLoop(ctx, remoteKvClient, stateCache) txpoolConn := conn diff --git a/docs/site/docs/fundamentals/modules/rpc-daemon.md b/docs/site/docs/fundamentals/modules/rpc-daemon.md index 997a0041508..f156d277a86 100644 --- a/docs/site/docs/fundamentals/modules/rpc-daemon.md +++ b/docs/site/docs/fundamentals/modules/rpc-daemon.md @@ -117,7 +117,7 @@ Flags: --rpc.txfeecap float Sets a cap on transaction fee (in ether) that can be sent via the RPC APIs (0 = no cap) (default 1) --socket.enabled Enable IPC server --socket.url string IPC server listening url. prefix supported are tcp, unix (default "unix:///var/run/erigon.sock") - --state.cache string Amount of data to store in StateCache (enabled if no --datadir set). Set 0 to disable StateCache. Defaults to 0MB RAM (default "0MB") + --state.cache string Amount of data to store in the version-keyed StateCache. Set 0 to fall back to a non-versioned last-block cache (default "128MB") --tls.cacert string CA certificate for client side TLS handshake for GRPC --tls.cert string certificate for client side TLS handshake for GRPC --tls.key string key file for client side TLS handshake for GRPC From 0672f471cf5f75e6a015c8f47b6bf9dfc648bb27 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Mon, 6 Jul 2026 13:47:39 +0200 Subject: [PATCH 14/45] engineapi: tolerate Busy when executing downloaded batches With FcuBackgroundCommit on by default, a background commit briefly holds the exec semaphore after every FCU, so ValidateChain/UpdateForkChoice can return ExecutionStatusBusy during batch execution. execDownloadedBatch treated any non-Success status as fatal and aborted the download; wait out Busy instead (ctx-aware), matching processReq and polygon's retryBusy. --- .../block_downloader.go | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/execution/engineapi/engine_block_downloader/block_downloader.go b/execution/engineapi/engine_block_downloader/block_downloader.go index 3c6d6c93351..fd578afe6fc 100644 --- a/execution/engineapi/engine_block_downloader/block_downloader.go +++ b/execution/engineapi/engine_block_downloader/block_downloader.go @@ -233,6 +233,19 @@ func (e *EngineBlockDownloader) execDownloadedBatch(ctx context.Context, block * if err != nil { return err } + // A background FCU commit briefly holds the exec semaphore; wait it out + // instead of failing the batch. + for status == execmodule.ExecutionStatusBusy { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(50 * time.Millisecond): + } + status, _, lastValidHash, err = e.chainRW.ValidateChain(ctx, block.Hash(), block.NumberU64()) + if err != nil { + return err + } + } switch status { case execmodule.ExecutionStatusBadBlock: e.ReportBadHeader(block.Hash(), lastValidHash) @@ -257,6 +270,17 @@ func (e *EngineBlockDownloader) execDownloadedBatch(ctx context.Context, block * if err != nil { return err } + for fcuStatus == execmodule.ExecutionStatusBusy { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(50 * time.Millisecond): + } + fcuStatus, _, lastValidHash, err = e.chainRW.UpdateForkChoice(ctx, block.Hash(), common.Hash{}, common.Hash{}, 0) + if err != nil { + return err + } + } if fcuStatus != execmodule.ExecutionStatusSuccess { return fmt.Errorf( "unsuccessful status when updating fork choice for batch download: status=%s, tip=%s, latestValidHash=%s", From 9eb4bc4d8ffb8d5afd0dd87c6cf058e43e987664 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Mon, 6 Jul 2026 13:47:40 +0200 Subject: [PATCH 15/45] rpc/jsonrpc: reuse BaseAPI header helpers in bor GetAuthor headerByNumber/headerByHash already resolve overlay-aware and check the blocks LRU; drop the hand-rolled WithOverlay + blockReader calls. --- rpc/jsonrpc/bor_api_impl.go | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/rpc/jsonrpc/bor_api_impl.go b/rpc/jsonrpc/bor_api_impl.go index 8aead3b4114..32754dbbc15 100644 --- a/rpc/jsonrpc/bor_api_impl.go +++ b/rpc/jsonrpc/bor_api_impl.go @@ -100,23 +100,12 @@ func (api *BorImpl) GetAuthor(blockNrOrHash *rpc.BlockNumberOrHash) (accounts.Ad // Retrieve the requested block number (or current if none requested) var header *types.Header - - //nolint:nestif if blockNrOrHash == nil { - overlayTx := api.filters.WithOverlay(tx) - latestBlockNum, err2 := rpchelper.GetLatestBlockNumber(overlayTx) - if err2 != nil { - return accounts.NilAddress, err2 - } - header, err = api._blockReader.HeaderByNumber(ctx, overlayTx, latestBlockNum) - } else { - if blockNr, ok := blockNrOrHash.Number(); ok { - header, err = api._blockReader.HeaderByNumber(ctx, tx, uint64(blockNr)) - } else { - if blockHash, ok := blockNrOrHash.Hash(); ok { - header, err = api._blockReader.HeaderByHash(ctx, tx, blockHash) - } - } + header, err = api.headerByNumber(ctx, rpc.LatestBlockNumber, tx) + } else if blockNr, ok := blockNrOrHash.Number(); ok { + header, err = api.headerByNumber(ctx, blockNr, tx) + } else if blockHash, ok := blockNrOrHash.Hash(); ok { + header, err = api.headerByHash(ctx, blockHash, tx) } // Ensure we have an actually valid block and return its snapshot From 92aa2a426cac538cf62cc94a5dde1b9930df6832 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Mon, 6 Jul 2026 13:47:40 +0200 Subject: [PATCH 16/45] db, rpc, node, cmd, execution: condense FcuBackgroundCommit comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-callsite overlay-vs-committed-view essays with terse pointers and state the contract once, on rpchelper.GetBlockNumber's filters param (including 'pending' falling back to latest-executed when nil). Trim the safe-concurrent-close narratives on MemoryMutation, SharedDomains.Close and Filters.WithOverlay to the distilled invariant — the newReadViewMut assertion is the enforcement — and shrink the ethconfig default's doc block to the semantics and the #21314 limitation pointer. --- cmd/utils/app/import_cmd.go | 9 +--- db/kv/membatchwithdb/memory_mutation.go | 41 ++++--------------- db/state/execctx/domain_shared.go | 22 ++-------- .../execmoduletester/exec_module_tester.go | 6 +-- node/ethconfig/config.go | 32 +++------------ rpc/jsonrpc/debug_api.go | 7 +--- rpc/jsonrpc/debug_execution_witness.go | 3 +- rpc/jsonrpc/erigon_block.go | 6 +-- rpc/jsonrpc/erigon_receipts.go | 5 +-- rpc/jsonrpc/eth_call.go | 22 +++------- rpc/jsonrpc/eth_receipts.go | 13 ++---- rpc/jsonrpc/eth_simulation.go | 5 +-- rpc/jsonrpc/overlay_api.go | 10 ++--- rpc/jsonrpc/parity_api.go | 8 +--- rpc/jsonrpc/trace_filtering.go | 6 +-- rpc/rpchelper/filters.go | 15 +------ rpc/rpchelper/helper.go | 15 ++++--- 17 files changed, 55 insertions(+), 170 deletions(-) diff --git a/cmd/utils/app/import_cmd.go b/cmd/utils/app/import_cmd.go index 83da2f234e5..002cb1b7100 100644 --- a/cmd/utils/app/import_cmd.go +++ b/cmd/utils/app/import_cmd.go @@ -413,13 +413,8 @@ func InsertChain(ethereum *eth.Ethereum, chain *blockgen.ChainPack, setHead bool } } - // Under FcuBackgroundCommit the FCU returns Success before the MDBX - // commit lands; the bg goroutine writes Headers, BlockHash, HeadBlockHash - // etc. from the overlay. Open the RW tx below only after that bg - // goroutine has released the FCU semaphore, otherwise our WriteHeadBlockHash - // can commit before the overlay flush — leaving HeadBlockHash pointing at - // a header not yet in MDBX, which crashes the next startup in - // BlockReader.CurrentBlock. + // Wait for the FCU background commit so HeadBlockHash can't land in MDBX + // ahead of the header it points to. ethereum.ExecutionModule().WaitIdle(ethereum.SentryCtx()) return ethereum.ChainDB().Update(ethereum.SentryCtx(), func(tx kv.RwTx) error { diff --git a/db/kv/membatchwithdb/memory_mutation.go b/db/kv/membatchwithdb/memory_mutation.go index 11d3128d056..19e9b8d5ca6 100644 --- a/db/kv/membatchwithdb/memory_mutation.go +++ b/db/kv/membatchwithdb/memory_mutation.go @@ -562,25 +562,8 @@ func (m *MemoryMutation) Commit() error { return nil } -// Rollback releases this mutation's local cursor cache and forwards Rollback -// to the backing in-memory tx / db. -// -// Concurrency invariant (load-bearing — see also Filters.WithOverlay): for a -// MemoryMutation created via NewMemoryBatch (the pure-Go memStore backing -// used by SharedDomains.blockOverlay), memTx.Rollback and memDb.Close are -// no-ops on the in-memory data — see memory_store.go. That is what makes it -// safe for the FCU bg-commit goroutine to call Close on the published -// BlockOverlay while concurrent RPC readers are still iterating views -// obtained via NewReadView / NewTemporalReadView: the views share memTx but -// the data behind it is not destroyed. Statefulness affecting the parent's -// own statelessCursors map below does not propagate to views (views inherit -// an independent lazy cursor cache via newReadViewMut). -// -// If this MemoryMutation is ever switched to NewMemoryBatchMDBX (real MDBX -// backing, where Rollback DOES invalidate cursors), the bg-commit close + -// concurrent-RPC-reader pattern becomes unsafe and refcounting/drain logic -// is required. newReadViewMut enforces this at runtime via the *memStore -// type-assertion. +// Safe to close while read views are still iterating: the memStore backing +// makes Rollback a no-op on the data (asserted in newReadViewMut). func (m *MemoryMutation) Rollback() { m.memTx.Rollback() m.memDb.Close() @@ -988,13 +971,8 @@ func (m *MemoryMutation) Unwind(ctx context.Context, txNumUnwindTo uint64, chang // // The returned kv.TemporalTx only exposes read methods. Callers cannot write // to the overlay through this view. The caller must not Close the returned -// view (it doesn't own the memDb). -// -// Concurrency: the view remains safe to read even if the parent's Close / -// Rollback runs concurrently (e.g. the FCU bg-commit goroutine closing the -// published BlockOverlay) — but only because the parent is memStore-backed, -// whose Rollback/Close are no-ops on the in-memory data. See newReadViewMut -// for the runtime assertion enforcing that invariant. +// view (it doesn't own the memDb). Safe under a concurrent parent Close — +// see newReadViewMut. func (m *MemoryMutation) NewReadView(tx kv.Tx) kv.TemporalTx { return m.newReadViewMut(tx) } @@ -1002,13 +980,10 @@ func (m *MemoryMutation) NewReadView(tx kv.Tx) kv.TemporalTx { // 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 { - // Enforce the safe-concurrent-close invariant documented on Rollback and - // on NewReadView: read views remain safe under a concurrent parent Close - // only because the pure-Go memStore's Rollback/Close are no-ops. An - // MDBX-backed memTx (NewMemoryBatchMDBX) would invalidate cursors on - // Rollback and break readers mid-iteration. If you need MDBX-backed - // overlays with concurrent readers, add a refcount/drain step at the - // parent's Close before relaxing this assertion. + // Read views stay safe under a concurrent parent Close only because the + // pure-Go memStore's Rollback/Close are no-ops on its data; an MDBX-backed + // memTx would invalidate cursors mid-iteration. Relaxing this assertion + // requires refcount/drain logic at the parent's Close. if _, ok := m.memTx.(*memStore); !ok { panic(fmt.Sprintf("MemoryMutation.newReadViewMut: shared-tx read views require pure-Go memStore backing; got %T (use NewMemoryBatch, not NewMemoryBatchMDBX)", m.memTx)) } diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index bd7a63f91c1..74919a98431 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -626,25 +626,9 @@ func (sd *SharedDomains) IteratePrefix(domain kv.Domain, prefix []byte, roTx kv. // Close releases this SD's in-memory state. Idempotent. // -// Concurrency with RPC readers: when the FCU bg-commit goroutine calls Close -// on the published SD (forkchoice.go bg path), concurrent RPC readers that -// obtained a view via Filters.WithOverlay / WithTemporalOverlay / -// SharedDomains.BlockOverlayTemporalTx remain safe. Why: -// - sd.mem (TemporalMemBatch) holds domain writes that are never exposed -// to RPC views — the temporal methods on the block overlay's views -// delegate to the caller's own tx, not to sd.mem. Closing sd.mem does -// not reach into any view. -// - sd.blockOverlay (MemoryMutation on memStore) has no-op Rollback/Close -// for its in-memory data — see MemoryMutation.Rollback. Views' shared -// memTx therefore keeps serving reads after the parent's Close. -// - sd.sdCtx is the commitment context, internal to this SD; views don't -// reference it. -// -// In other words: PublishOverlay(nil) before this Close blocks NEW readers -// from acquiring the overlay, and the no-op-on-data semantics keeps already- -// acquired views readable until the caller's own tx is rolled back. Do not -// change BlockOverlay's backing store to one that destroys data on close -// without adding a refcount/drain step here (see newReadViewMut). +// Safe to call while readers still hold block-overlay views: the overlay's +// memStore backing keeps their data alive (see MemoryMutation.newReadViewMut), +// and sd.mem is never exposed to those views. func (sd *SharedDomains) Close() { if sd.sdCtx == nil { //idempotency return diff --git a/execution/execmodule/execmoduletester/exec_module_tester.go b/execution/execmodule/execmoduletester/exec_module_tester.go index 1407ac3d458..efeced63107 100644 --- a/execution/execmodule/execmoduletester/exec_module_tester.go +++ b/execution/execmodule/execmoduletester/exec_module_tester.go @@ -874,10 +874,8 @@ func (emt *ExecModuleTester) InsertChain(chain *blockgen.ChainPack) error { if err := emt.insertPoSBlocks(chain); err != nil { return err } - // Under FcuBackgroundCommit, UpdateForkChoice returns Success before the - // MDBX commit lands. The state-change events insertPoSBlocks waits on are - // dispatched pre-commit. Block until the bg goroutine releases the FCU - // semaphore so the DB reads below observe the committed state. + // UpdateForkChoice can return before the MDBX commit lands; wait so the + // reads below see committed state. emt.ExecModule.WaitIdle(emt.Ctx) roTx, err := emt.DB.BeginRo(emt.Ctx) if err != nil { diff --git a/node/ethconfig/config.go b/node/ethconfig/config.go index 64849b663a2..d3555996261 100644 --- a/node/ethconfig/config.go +++ b/node/ethconfig/config.go @@ -113,33 +113,11 @@ var Defaults = Config{ }, FcuTimeout: 1 * time.Second, FcuBackgroundPrune: true, - // FcuBackgroundCommit lets the FCU response return to the consensus client - // before MDBX commit lands. Notifications are dispatched pre-commit from the - // SharedDomains overlay (see execution/execmodule/notification_dispatcher.go). - // Successive FCUs are serialized through the ExecModule semaphore so FCU N+1 - // always reads FCU N's committed state. - // - // Embedded rpcdaemon: head-sensitive paths whose dependent reads are also - // overlay-backed (canonical hashes, headers, bodies, stage progress, TxNums) - // consult filters.LatestSD()/WithOverlay (see rpc/rpchelper/filters.go), so - // they see the new head without waiting for fsync. Paths whose dependent - // reads use SD-temporal data (eth_call, getProof, witness, simulation, log - // range scans) intentionally stay on the committed plain tx to avoid a - // head-vs-state divergence. - // - // Remote rpcdaemon: kvcache.Coherent receives pre-commit StateChanges and - // the matching root is keyed by PlainStateVersion, which is bumped atomically - // inside the commit batch — remote txs observe a consistent (lagging) state, - // not a divergent one. - // - // Known limitation: during the ~50ms window between FCU response and MDBX - // commit, "latest"-anchored *state* reads (eth_call(latest), eth_getBalance, - // eth_getStorageAt, eth_getCode) return block N's header but evaluate against - // block N-1's state — the SD's domain mem batch is not exposed by the block - // overlay. A proper SD-aware temporal view that chains SD.mem → block - // overlay → committed MDBX is tracked in - // https://github.com/erigontech/erigon/issues/21314. The trade-off here is - // bounded staleness (~50ms), not corruption. + // FcuBackgroundCommit returns the FCU response before the MDBX commit + // lands; the commit runs in a background goroutine and successive FCUs are + // serialized by the ExecModule semaphore. Until the SD-aware temporal view + // (https://github.com/erigontech/erigon/issues/21314) lands, "latest" state + // reads can lag the announced head by one block for the commit's duration. FcuBackgroundCommit: true, ExperimentalBAL: false, } diff --git a/rpc/jsonrpc/debug_api.go b/rpc/jsonrpc/debug_api.go index ec81a0a8d25..004e8521bdc 100644 --- a/rpc/jsonrpc/debug_api.go +++ b/rpc/jsonrpc/debug_api.go @@ -105,11 +105,8 @@ func (api *DebugAPIImpl) SetHead(ctx context.Context, number hexutil.Uint64) err } defer tx.Rollback() - // Read head through the overlay so a no-op debug_setHead(N) issued during - // the bg-commit window (when the overlay says N but MDBX is still at N-1) - // doesn't false-positive "block N is in the future". The guard is a pure - // number comparison and SetHead itself runs through ethBackend on the - // committed DB after the bg goroutine releases the FCU semaphore. + // Overlay-aware head, so setHead(N) isn't rejected as future while N's + // commit is still in flight. currentHead, err := rpchelper.GetLatestBlockNumber(api.filters.WithOverlay(tx)) if err != nil { return err diff --git a/rpc/jsonrpc/debug_execution_witness.go b/rpc/jsonrpc/debug_execution_witness.go index 1c19e50a224..035ae929c24 100644 --- a/rpc/jsonrpc/debug_execution_witness.go +++ b/rpc/jsonrpc/debug_execution_witness.go @@ -1113,8 +1113,7 @@ func (api *DebugAPIImpl) buildExpectedPostState( postSdCtx.SetDeferBranchUpdates(false) // Set up to read state at current block (after execution). - // Stay on the committed view: the branch below reads txnums and seeks - // commitment against plain tx, so latestBlock must agree with that view. + // Committed view: the txnum/commitment reads below use the same plain tx. latestBlock, err := rpchelper.GetLatestBlockNumber(tx) if err != nil { return nil, nil, fmt.Errorf("failed to get latest block: %w", err) diff --git a/rpc/jsonrpc/erigon_block.go b/rpc/jsonrpc/erigon_block.go index 4e313972855..1f2d0ed0345 100644 --- a/rpc/jsonrpc/erigon_block.go +++ b/rpc/jsonrpc/erigon_block.go @@ -94,10 +94,8 @@ func (api *ErigonImpl) GetBlockByTimestamp(ctx context.Context, timeStamp rpc.Ti uintTimestamp := timeStamp.TurnIntoUint64() - // Route every block-table read (head, binary search, final block lookup) - // through the overlay so all bounds and reads agree on a single view - // during the bg-commit window. Only block tables are consulted — no - // SD-temporal reads — so overlay-aware is fully consistent here. + // Everything here is a block-table read, so one overlay view keeps the + // head, the search bounds, and the lookups consistent. overlayTx := api.filters.WithOverlay(tx) currentHeader := rawdb.ReadCurrentHeader(overlayTx) if currentHeader == nil { diff --git a/rpc/jsonrpc/erigon_receipts.go b/rpc/jsonrpc/erigon_receipts.go index 55e91328fed..0b5a5e1bed1 100644 --- a/rpc/jsonrpc/erigon_receipts.go +++ b/rpc/jsonrpc/erigon_receipts.go @@ -97,8 +97,7 @@ func (api *ErigonImpl) GetLogs(ctx context.Context, crit filters.FilterCriteria) end = header.Number.Uint64() } else { - // Stay on the committed view: getLogsV3 scans logs against the same tx, - // so the latest cap and the scan must agree. + // Committed view: must agree with the log scan below. latest, err := rpchelper.GetLatestBlockNumber(tx) if err != nil { return nil, err @@ -193,7 +192,7 @@ func (api *ErigonImpl) GetLatestLogs(ctx context.Context, crit filters.FilterCri begin = header.Number.Uint64() end = header.Number.Uint64() } else { - // Stay on the committed view: getLogsV3 scans against the same tx. + // Committed view: must agree with the log scan below. latest, err := rpchelper.GetLatestBlockNumber(tx) if err != nil { return nil, err diff --git a/rpc/jsonrpc/eth_call.go b/rpc/jsonrpc/eth_call.go index b3d44e30a98..e4f59d9a47c 100644 --- a/rpc/jsonrpc/eth_call.go +++ b/rpc/jsonrpc/eth_call.go @@ -73,17 +73,9 @@ func (api *APIImpl) Call(ctx context.Context, args ethapi2.CallArgs, requestedBl } defer roTx.Rollback() - // Use the block overlay if available so block-tag resolution and header/body - // reads see uncommitted data from the pre-commit overlay. - // - // Note: BlockOverlayTemporalTx wraps *table* reads (canonical hashes, - // headers, stage progress) but its temporal methods (GetLatest, GetAsOf, - // RangeAsOf, HistorySeek) delegate to the underlying roTx — the SD's domain - // mem batch is not exposed. During the bg-commit window (FcuBackgroundCommit), - // "latest" resolves to block N via the overlay tables, but state reads - // evaluate against block N-1's committed domain state. This is a bounded - // staleness (~50ms), not a divergence; the proper SD-aware temporal view is - // tracked in https://github.com/erigontech/erigon/issues/21314. + // The overlay exposes block tables only: "latest" resolves to the + // pre-commit head while temporal state reads still see the last committed + // block (see ethconfig.Defaults.FcuBackgroundCommit). var tx kv.TemporalTx = roTx if api.filters != nil { if sd := api.filters.LatestSD(); sd != nil { @@ -476,10 +468,7 @@ func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address co sdCtx := domains.GetCommitmentContext() sdCtx.SetDeferBranchUpdates(false) - // Stay on the committed view: the downstream proof computation reads - // txnums, history, and state through roTx + the SD without consulting - // the overlay, so latestBlock must match that view to keep the guard - // consistent. + // Committed view: the proof computation below reads the same plain roTx. latestBlock, err := rpchelper.GetLatestBlockNumber(roTx) if err != nil { return nil, err @@ -687,8 +676,7 @@ func (api *BaseAPI) getWitness(ctx context.Context, db kv.TemporalRoDB, blockNrO return nil, fmt.Errorf("transaction index out of bounds: %d", txIndex) } - // Stay on the committed view: regenerateHash / the witness rewind below - // operate against roTx without overlay awareness. + // Committed view: the witness rewind below reads the same plain roTx. latestBlock, err := rpchelper.GetLatestBlockNumber(roTx) if err != nil { return nil, err diff --git a/rpc/jsonrpc/eth_receipts.go b/rpc/jsonrpc/eth_receipts.go index 124ba839ce1..fb8cd7a0b2d 100644 --- a/rpc/jsonrpc/eth_receipts.go +++ b/rpc/jsonrpc/eth_receipts.go @@ -123,13 +123,8 @@ func (api *APIImpl) GetLogs(ctx context.Context, crit filters.FilterCriteria) (t begin = num end = num } else { - // Resolve block tags on the committed view: getLogsV3 scans logs against - // the plain tx and the begin > latest / end > latest guards below compare - // against `latest` resolved here, so all three must agree. Passing - // api.filters would route "latest"/"safe"/"finalized" through the SD - // overlay during the bg-commit window, leaving begin or end at N while - // `latest` and the scan are still at N-1 — the range check would then - // false-positive errBlockRangeIntoFuture. + // nil filters: resolve tags on the committed view the log scan reads + // (see rpchelper.GetBlockNumber). latest, _, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(rpc.LatestExecutedBlockNumber), tx, api._blockReader, nil) if err != nil { return nil, err @@ -175,9 +170,7 @@ func (api *APIImpl) GetLogs(ctx context.Context, crit filters.FilterCriteria) (t return nil, &rpc.CustomError{Message: errInvalidBlockRange, Code: rpc.ErrCodeInvalidParams} } if end > roaring.MaxUint32 { - // Stay on the committed view: the GetLatestExecutedBlockNumber guard - // below uses plain tx, and getLogsV3 scans logs against the same tx, - // so the latest cap must agree with both. + // Committed view: must agree with the scan below. latest, err := rpchelper.GetLatestBlockNumber(tx) if err != nil { return nil, err diff --git a/rpc/jsonrpc/eth_simulation.go b/rpc/jsonrpc/eth_simulation.go index e59fd3d4824..dca237c66bb 100644 --- a/rpc/jsonrpc/eth_simulation.go +++ b/rpc/jsonrpc/eth_simulation.go @@ -129,10 +129,7 @@ func (api *APIImpl) SimulateV1(ctx context.Context, req SimulationRequest, block if err != nil { return nil, err } - // Stay on the committed view: NewSharedDomains below builds the simulator - // on plain tx, so state reads only see committed domain data (the SD's mem - // batch is not exposed by the block overlay). An overlay-aware latest could - // land at N while the simulator can only reach state at N-1. + // Committed view: the simulator below reads state through the same plain tx. latestBlockNumber, err := rpchelper.GetLatestBlockNumber(tx) if err != nil { return nil, err diff --git a/rpc/jsonrpc/overlay_api.go b/rpc/jsonrpc/overlay_api.go index f4b8e4ebbea..90fb45052a5 100644 --- a/rpc/jsonrpc/overlay_api.go +++ b/rpc/jsonrpc/overlay_api.go @@ -589,11 +589,8 @@ func getBeginEnd(ctx context.Context, tx kv.Tx, api *OverlayAPIImpl, crit filter begin = num end = num } else { - // Resolve block tags on the committed view: the caller scans logs/ - // receipts against the same tx (and the MaxUint32 cap below also reads - // `latest` from plain tx), so all references must agree. Passing - // api.filters would route "latest" through the SD overlay during the - // bg-commit window, landing on N while the scan and caps are at N-1. + // nil filters: resolve tags on the committed view the caller scans + // (see rpchelper.GetBlockNumber). latest, _, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(rpc.LatestExecutedBlockNumber), tx, api._blockReader, nil) if err != nil { return 0, 0, err @@ -632,8 +629,7 @@ func getBeginEnd(ctx context.Context, tx kv.Tx, api *OverlayAPIImpl, crit filter return 0, 0, fmt.Errorf("end (%d) < begin (%d)", end, begin) } if end > roaring.MaxUint32 { - // Stay on the committed view: the caller scans logs/receipts against - // the same tx, so the upper bound must agree with what the scan can see. + // Committed view: must agree with the scan. latest, err := rpchelper.GetLatestBlockNumber(tx) if err != nil { return 0, 0, err diff --git a/rpc/jsonrpc/parity_api.go b/rpc/jsonrpc/parity_api.go index cab40fc432b..36fa1d9819b 100644 --- a/rpc/jsonrpc/parity_api.go +++ b/rpc/jsonrpc/parity_api.go @@ -73,12 +73,8 @@ func (api *ParityAPIImpl) ListStorageKeys(ctx context.Context, account common.Ad return nil, errors.New("acc not found") } - // Stay on the committed view: bn, _txNumReader.Min, and the RangeAsOf scan - // over kv.StorageDomain must agree on a single state version. The block - // overlay exposes table writes but not the SD's domain mem batch, so an - // overlay-derived bn would point at a block whose StorageDomain writes - // are not yet visible to RangeAsOf, returning either an error or - // inconsistent storage. + // Committed view: bn must match the state version the RangeAsOf scan + // below can see (the overlay exposes block tables, not domain data). bn := rawdb.ReadCurrentBlockNumber(tx) if bn == nil { return nil, errors.New("current block number not found") diff --git a/rpc/jsonrpc/trace_filtering.go b/rpc/jsonrpc/trace_filtering.go index 3b15b15aed4..e00e3246d95 100644 --- a/rpc/jsonrpc/trace_filtering.go +++ b/rpc/jsonrpc/trace_filtering.go @@ -329,10 +329,8 @@ func (api *TraceAPIImpl) Filter(ctx context.Context, req TraceFilterRequest, gas var fromBlock uint64 var toBlock uint64 var err error - // filterV3 below scans receipts/logs against dbtx; resolve every block tag - // on the same committed view so the upper bound and the scan agree. Routing - // "latest"/"safe"/"finalized" through api.filters would land on a block - // whose receipts are not yet committed during the bg-commit window. + // nil filters: resolve tags on the committed view filterV3 scans + // (see rpchelper.GetBlockNumber). if req.FromBlock == nil { fromBlock = 0 } else { diff --git a/rpc/rpchelper/filters.go b/rpc/rpchelper/filters.go index f7f8a222788..52e644ae866 100644 --- a/rpc/rpchelper/filters.go +++ b/rpc/rpchelper/filters.go @@ -895,19 +895,8 @@ func (ff *Filters) LatestSD() *execctx.SharedDomains { // tx for data not in the overlay. // Safe to call on a nil receiver. // -// Concurrency: the returned view stays safe to use even if the FCU bg-commit -// goroutine concurrently closes the published SD (the typical lifecycle — -// PublishOverlay(SD) → reader acquires view → reader uses view → bg goroutine -// PublishOverlay(nil) + SD.Close → reader continues for some time → caller's -// tx.Rollback). This is load-bearing on (a) the BlockOverlay being backed by -// a pure-Go memStore whose Rollback/Close are no-ops on the in-memory data -// (see MemoryMutation.Rollback and memory_store.go) and (b) the view holding -// its own backing tx (the caller's tx, not the SD's). Both invariants are -// asserted at view-construction time in MemoryMutation.newReadViewMut. Do -// not relax either without adding refcount/drain logic to SharedDomains.Close. -// -// The standard tx lifecycle still applies: views must not be used after the -// CALLER'S tx is rolled back. +// The view stays readable if the publisher closes the SD concurrently (see +// MemoryMutation.newReadViewMut); it must not outlive the caller's tx. func (ff *Filters) WithOverlay(tx kv.Tx) kv.Tx { if ff == nil { return tx diff --git a/rpc/rpchelper/helper.go b/rpc/rpchelper/helper.go index 82e600759cc..af348540429 100644 --- a/rpc/rpchelper/helper.go +++ b/rpc/rpchelper/helper.go @@ -57,6 +57,15 @@ func CheckBlockExecuted(tx kv.Tx, blockNumber uint64) error { return nil } +// GetBlockNumber resolves a block number, hash, or tag ("latest", "safe", +// "finalized", "pending") to a concrete block number and hash. +// +// filters controls which view tags resolve against. Pass the API's Filters to +// resolve through the block overlay, which includes a head whose commit is +// still in flight. Pass nil to resolve purely on the committed view of tx — +// required when the caller then scans data through the same plain tx, so the +// bounds and the scan agree; "pending" then falls back to the latest executed +// block. func GetBlockNumber(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash, tx kv.Tx, br services.FullBlockReader, filters *Filters) (uint64, common.Hash, bool, error) { bn, bh, latest, found, err := _GetBlockNumber(ctx, blockNrOrHash.RequireCanonical, blockNrOrHash, tx, br, filters) if err != nil { @@ -120,11 +129,7 @@ func _GetBlockNumber(ctx context.Context, requireCanonical bool, blockNrOrHash r return 0, common.Hash{}, false, false, err } case rpc.PendingBlockNumber: - // filters may be nil here: callers that intentionally disable - // overlay-aware resolution (log-range scans against committed tx) - // pass nil. Treat that as "no pending block known" and fall back - // to plainStateBlockNumber — same as when filters is set but has - // no pending block. + // nil filters (committed-view resolution) = no pending block known. if filters != nil { if pendingBlock := filters.LastPendingBlock(); pendingBlock != nil { return pendingBlock.NumberU64(), pendingBlock.Hash(), false, true, nil From 798d1a9a7e077228b33300277214cea9811f2117 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Mon, 6 Jul 2026 16:08:39 +0200 Subject: [PATCH 17/45] docs: regenerate llms-full.txt for state.cache flag change generate-llms.py --check in docs-site CI diffs the committed files against the regenerated output, which embeds the rpc-daemon.md flag reference. --- docs/site/static/llms-full.txt | 2 +- llms-full.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/site/static/llms-full.txt b/docs/site/static/llms-full.txt index d1fc1b78f22..00cd62c2cfc 100644 --- a/docs/site/static/llms-full.txt +++ b/docs/site/static/llms-full.txt @@ -4070,7 +4070,7 @@ Flags: --rpc.txfeecap float Sets a cap on transaction fee (in ether) that can be sent via the RPC APIs (0 = no cap) (default 1) --socket.enabled Enable IPC server --socket.url string IPC server listening url. prefix supported are tcp, unix (default "unix:///var/run/erigon.sock") - --state.cache string Amount of data to store in StateCache (enabled if no --datadir set). Set 0 to disable StateCache. Defaults to 0MB RAM (default "0MB") + --state.cache string Amount of data to store in the version-keyed StateCache. Set 0 to fall back to a non-versioned last-block cache (default "128MB") --tls.cacert string CA certificate for client side TLS handshake for GRPC --tls.cert string certificate for client side TLS handshake for GRPC --tls.key string key file for client side TLS handshake for GRPC diff --git a/llms-full.txt b/llms-full.txt index d1fc1b78f22..00cd62c2cfc 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -4070,7 +4070,7 @@ Flags: --rpc.txfeecap float Sets a cap on transaction fee (in ether) that can be sent via the RPC APIs (0 = no cap) (default 1) --socket.enabled Enable IPC server --socket.url string IPC server listening url. prefix supported are tcp, unix (default "unix:///var/run/erigon.sock") - --state.cache string Amount of data to store in StateCache (enabled if no --datadir set). Set 0 to disable StateCache. Defaults to 0MB RAM (default "0MB") + --state.cache string Amount of data to store in the version-keyed StateCache. Set 0 to fall back to a non-versioned last-block cache (default "128MB") --tls.cacert string CA certificate for client side TLS handshake for GRPC --tls.cert string certificate for client side TLS handshake for GRPC --tls.key string key file for client side TLS handshake for GRPC From 9ba4e1e8fc09c2b86eae6046ecc959c138ae2b51 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Mon, 6 Jul 2026 16:22:08 +0200 Subject: [PATCH 18/45] node/ethconfig, docs: keep FcuBackgroundCommit off by default The default flip moves to a follow-up PR stacked on this one, so this PR carries only the correctness and consistency groundwork. --- docs/site/docs/fundamentals/configuring-erigon.mdx | 2 +- docs/site/static/llms-full.txt | 2 +- llms-full.txt | 2 +- node/ethconfig/config.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/site/docs/fundamentals/configuring-erigon.mdx b/docs/site/docs/fundamentals/configuring-erigon.mdx index 297998028b8..196bee7bf22 100644 --- a/docs/site/docs/fundamentals/configuring-erigon.mdx +++ b/docs/site/docs/fundamentals/configuring-erigon.mdx @@ -412,7 +412,7 @@ Flags for configuring Fork Choice Update behavior. * `--fcu.background.prune`: Enables background pruning after FCU. * Default: `true` * `--fcu.background.commit`: Returns the FCU response before MDBX flush+commit lands (commit runs in background; remote `rpcdaemon` `latest` lags ~50ms, stays consistent). - * Default: `true` + * Default: `false` ### Execution diff --git a/docs/site/static/llms-full.txt b/docs/site/static/llms-full.txt index 00cd62c2cfc..eca381d36ec 100644 --- a/docs/site/static/llms-full.txt +++ b/docs/site/static/llms-full.txt @@ -2278,7 +2278,7 @@ Flags for configuring Fork Choice Update behavior. * `--fcu.background.prune`: Enables background pruning after FCU. * Default: `true` * `--fcu.background.commit`: Returns the FCU response before MDBX flush+commit lands (commit runs in background; remote `rpcdaemon` `latest` lags ~50ms, stays consistent). - * Default: `true` + * Default: `false` ### Execution diff --git a/llms-full.txt b/llms-full.txt index 00cd62c2cfc..eca381d36ec 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -2278,7 +2278,7 @@ Flags for configuring Fork Choice Update behavior. * `--fcu.background.prune`: Enables background pruning after FCU. * Default: `true` * `--fcu.background.commit`: Returns the FCU response before MDBX flush+commit lands (commit runs in background; remote `rpcdaemon` `latest` lags ~50ms, stays consistent). - * Default: `true` + * Default: `false` ### Execution diff --git a/node/ethconfig/config.go b/node/ethconfig/config.go index 3a2b0fff1ba..1e77c1866e0 100644 --- a/node/ethconfig/config.go +++ b/node/ethconfig/config.go @@ -118,7 +118,7 @@ var Defaults = Config{ // serialized by the ExecModule semaphore. Until the SD-aware temporal view // (https://github.com/erigontech/erigon/issues/21314) lands, "latest" state // reads can lag the announced head by one block for the commit's duration. - FcuBackgroundCommit: true, + FcuBackgroundCommit: false, ExperimentalBAL: false, WarmupKzgCtxOnInit: true, } From ee6c09741ec3f00b643ccd2cd104a8dbf3d0f897 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:21:30 +0200 Subject: [PATCH 19/45] execution, db, rpc: state-version parity, kvcache hardening, committed-view tags Address review findings: - notifications: announce the post-commit PlainStateVersion in the pre-commit overlay dispatch (new Dispatch preCommit param) so version-keyed caches match what committed readers observe; without this, announced ids were permanently committed-1 and no Coherent root ever matched a reader. Pinned by TestStateChangeVersionMatchesCommitted (fg + bg commit modes). - kvcache: a view whose root was evicted (outlived KeepViews) now falls back to its own tx snapshot instead of erroring "too old ViewID" mid-request; reads through non-latest views are no longer cached - they bypassed eviction accounting, growing without bound when no state-change stream feeds OnNewBlock (e.g. --datadir rpcdaemon while erigon is down). - rpc: resolve tags on the committed view (nil filters) where the dependent reads are temporal: eth_getProof, eth_simulateV1, debug_traceBlockBy*, debug_storageRangeAt, debug_accountRange, eth_getWitness. Overlay-wrap the block-table reads of debug_getRawHeader and BaseAPI.headerByHash (fixes latest-vs-by-hash inconsistency for all by-hash consumers). Consolidate bor GetSnapshot/GetSigners/GetSnapshotProposer{,Sequence} onto headerByNumber/headerByHash like GetAuthor. Guard nil head number in trace_filter. - engine_block_downloader: replace the two hand-rolled busy-poll loops with one retryBusy helper that keeps the fresh validationErr across retries (BadBlock after Busy no longer reports an empty reason) and logs periodically so a stuck background commit surfaces. - membatchwithdb: drop NewMemoryBatchMDBX (no callers); the read-view memStore invariant is now unviolatable by construction. - --state.cache: document that an equally-sized code cache is budgeted on top of the configured size. --- cmd/rpcdaemon/cli/config.go | 2 +- db/kv/kvcache/cache.go | 39 +++++----- db/kv/kvcache/cache_test.go | 78 +++++++++++++++++++ db/kv/membatchwithdb/memory_mutation.go | 36 +-------- .../docs/fundamentals/modules/rpc-daemon.md | 2 +- docs/site/static/llms-full.txt | 2 +- .../block_downloader.go | 50 ++++++------ execution/execmodule/exec_module_test.go | 57 +++++++++++++- .../execmoduletester/exec_module_tester.go | 8 +- execution/execmodule/forkchoice.go | 1 + .../execmodule/notification_dispatcher.go | 8 ++ execution/stagedsync/stageloop/stageloop.go | 7 +- llms-full.txt | 2 +- node/ethconfig/config.go | 5 +- rpc/jsonrpc/bor_api_impl.go | 60 +++++--------- rpc/jsonrpc/debug_api.go | 10 ++- rpc/jsonrpc/eth_api.go | 5 +- rpc/jsonrpc/eth_call.go | 8 +- rpc/jsonrpc/eth_simulation.go | 5 +- rpc/jsonrpc/trace_filtering.go | 3 + rpc/jsonrpc/tracing.go | 4 +- 21 files changed, 247 insertions(+), 145 deletions(-) diff --git a/cmd/rpcdaemon/cli/config.go b/cmd/rpcdaemon/cli/config.go index 4c5365f9b91..a03ad468491 100644 --- a/cmd/rpcdaemon/cli/config.go +++ b/cmd/rpcdaemon/cli/config.go @@ -137,7 +137,7 @@ func RootCommand() (*cobra.Command, *httpcfg.HttpCfg) { rootCmd.PersistentFlags().BoolVar(&cfg.GethCompatibility, "rpc.gethcompat", false, "Enables Geth-compatible storage iteration order for debug_storageRangeAt (sorted by keccak256 hash). Disabled by default for performance.") rootCmd.PersistentFlags().StringVar(&cfg.TxPoolApiAddr, "txpool.api.addr", "", "txpool api network address, for example: 127.0.0.1:9090 (default: use value of --private.api.addr)") - rootCmd.PersistentFlags().StringVar(&stateCacheStr, "state.cache", "128MB", "Amount of data to store in the version-keyed StateCache. Set 0 to fall back to a non-versioned last-block cache") + rootCmd.PersistentFlags().StringVar(&stateCacheStr, "state.cache", "128MB", "Amount of data to store in the version-keyed StateCache (an equally-sized code cache is budgeted on top). Set 0 to fall back to a non-versioned last-block cache") rootCmd.PersistentFlags().BoolVar(&cfg.GRPCServerEnabled, "grpc", false, "Enable GRPC server") rootCmd.PersistentFlags().StringVar(&cfg.GRPCListenAddress, "grpc.addr", nodecfg.DefaultGRPCHost, "GRPC server listening interface") rootCmd.PersistentFlags().IntVar(&cfg.GRPCPort, "grpc.port", nodecfg.DefaultGRPCPort, "GRPC server listening port") diff --git a/db/kv/kvcache/cache.go b/db/kv/kvcache/cache.go index 0a7096b97da..be7074bdd5a 100644 --- a/db/kv/kvcache/cache.go +++ b/db/kv/kvcache/cache.go @@ -367,7 +367,10 @@ func (c *Coherent) View(ctx context.Context, tx kv.TemporalTx) (CacheView, error } } -func (c *Coherent) getFromCache(k []byte, id uint64, domain kv.Domain) (*Element, *CoherentRoot, error) { +// getFromCache returns a nil root when the view's root was already evicted +// (the view outlived KeepViews version advances): the caller then reads +// through its own tx snapshot without caching. +func (c *Coherent) getFromCache(k []byte, id uint64, domain kv.Domain) (*Element, *CoherentRoot) { // using the full lock here rather than RLock as RLock causes a lot of calls to runtime.usleep degrading // performance under load c.lock.Lock() @@ -375,7 +378,7 @@ func (c *Coherent) getFromCache(k []byte, id uint64, domain kv.Domain) (*Element r, ok := c.roots[id] if !ok { - return nil, r, fmt.Errorf("too old ViewID: %d, latestStateVersionID=%d", id, c.latestStateVersionID) + return nil, nil } isLatest := c.latestStateVersionID == id @@ -388,17 +391,13 @@ func (c *Coherent) getFromCache(k []byte, id uint64, domain kv.Domain) (*Element if it != nil && isLatest { c.stateEvict.MoveToFront(it) } - return it, r, nil + return it, r } func (c *Coherent) Get(k []byte, tx kv.TemporalTx, id uint64) (v []byte, err error) { //TODO: Get must accept from user Domain parameter - it, r, err := c.getFromCache(k, id, kv.AccountsDomain) - if err != nil { - return nil, err - } + it, r := c.getFromCache(k, id, kv.AccountsDomain) if it != nil { - //fmt.Printf("from cache: %#x,%x\n", k, it.(*Element).V) c.hits.Inc() return it.V, nil } @@ -416,7 +415,9 @@ func (c *Coherent) Get(k []byte, tx kv.TemporalTx, id uint64) (v []byte, err err if len(v) == 0 { return v, nil } - //fmt.Printf("from db: %#x,%x\n", k, v) + if r == nil { + return v, nil + } c.lock.Lock() defer c.lock.Unlock() @@ -426,13 +427,9 @@ func (c *Coherent) Get(k []byte, tx kv.TemporalTx, id uint64) (v []byte, err err } func (c *Coherent) GetCode(k []byte, tx kv.TemporalTx, id uint64) (v []byte, err error) { - it, r, err := c.getFromCache(k, id, kv.CodeDomain) - if err != nil { - return nil, err - } + it, r := c.getFromCache(k, id, kv.CodeDomain) if it != nil { - //fmt.Printf("from cache: %#x,%x\n", k, it.(*Element).V) c.codeHits.Inc() return it.V, nil } @@ -442,7 +439,9 @@ func (c *Coherent) GetCode(k []byte, tx kv.TemporalTx, id uint64) (v []byte, err if err != nil { return nil, err } - //fmt.Printf("from db: %#x,%x\n", k, v) + if r == nil { + return v, nil + } c.lock.Lock() defer c.lock.Unlock() @@ -466,11 +465,13 @@ func (c *Coherent) removeOldestCode(r *CoherentRoot) { func (c *Coherent) add(k, v []byte, r *CoherentRoot, id uint64) *Element { it := &Element{K: k, V: v} - replaced, _ := r.cache.Set(it) + // Non-latest roots bypass eviction accounting, so growing them would be + // unbounded (e.g. with no state-change stream feeding OnNewBlock); the + // caller's tx read is authoritative for its snapshot, skip caching. if c.latestStateVersionID != id { - //fmt.Printf("add to non-last viewID: %d<%d\n", c.latestViewID, id) return it } + replaced, _ := r.cache.Set(it) if replaced != nil { c.stateEvict.Remove(replaced) } @@ -485,11 +486,11 @@ func (c *Coherent) add(k, v []byte, r *CoherentRoot, id uint64) *Element { } func (c *Coherent) addCode(k, v []byte, r *CoherentRoot, id uint64) *Element { it := &Element{K: k, V: v} - replaced, _ := r.codeCache.Set(it) + // see add if c.latestStateVersionID != id { - //fmt.Printf("add to non-last viewID: %d<%d\n", c.latestViewID, id) return it } + replaced, _ := r.codeCache.Set(it) if replaced != nil { c.codeEvict.Remove(replaced) } diff --git a/db/kv/kvcache/cache_test.go b/db/kv/kvcache/cache_test.go index 3673cfcd955..520b79377ef 100644 --- a/db/kv/kvcache/cache_test.go +++ b/db/kv/kvcache/cache_test.go @@ -169,6 +169,84 @@ func TestEviction(t *testing.T) { require.Equal(int(cfg.CacheSize.Bytes()), c.stateEvict.Size()) } +// A request whose cache view outlives KeepViews state-version advances (e.g. a +// long eth_call) must fall back to its own tx snapshot, not error out. +func TestViewSurvivesRootEviction(t *testing.T) { + require, ctx := require.New(t), t.Context() + cfg := DefaultCoherentConfig + cfg.NewBlockWait = 0 + c := New(cfg) + + db := temporaltest.NewTestDB(t, datadir.New(t.TempDir())) + k1 := [20]byte{1} + + err := db.ViewTemporal(ctx, func(tx kv.TemporalTx) error { + cacheView, err := c.View(ctx, tx) + require.NoError(err) + view := cacheView.(*CoherentView) + + for i := uint64(1); i <= cfg.KeepViews+2; i++ { + c.OnNewBlock(&remoteproto.StateChangeBatch{StateVersionId: view.stateVersionID + i}) + } + _, rootAlive := c.roots[view.stateVersionID] + require.False(rootAlive, "root must be evicted for this test to be meaningful") + + v, err := c.Get(k1[:], tx, view.stateVersionID) + require.NoError(err) + require.Empty(v) + + code, err := c.GetCode(k1[:], tx, view.stateVersionID) + require.NoError(err) + require.Empty(code) + return nil + }) + require.NoError(err) +} + +// Reads through a view whose version is not the latest (pre-commit window, or +// no state-change stream at all) bypass eviction accounting, so they must not +// grow the root either — otherwise memory is unbounded. +func TestNonLatestViewReadsAreNotCached(t *testing.T) { + require, ctx := require.New(t), t.Context() + cfg := DefaultCoherentConfig + cfg.NewBlockWait = 0 + c := New(cfg) + + db := temporaltest.NewTestDB(t, datadir.New(t.TempDir())) + k1 := [20]byte{1} + acc := accounts.Account{Nonce: 1, Balance: *uint256.NewInt(11), CodeHash: accounts.EmptyCodeHash} + accEnc := accounts.SerialiseV3(&acc) + + err := db.UpdateTemporal(ctx, func(tx kv.TemporalRwTx) error { + d, err := execctx.NewSharedDomains(ctx, tx, log.New()) + if err != nil { + return err + } + defer d.Close() + if err := d.DomainPut(kv.AccountsDomain, tx, k1[:], accEnc, 0, nil); err != nil { + return err + } + return d.Flush(ctx, tx) + }) + require.NoError(err) + + err = db.ViewTemporal(ctx, func(tx kv.TemporalTx) error { + cacheView, err := c.View(ctx, tx) + require.NoError(err) + view := cacheView.(*CoherentView) + require.NotEqual(c.latestStateVersionID, view.stateVersionID) + + v, err := c.Get(k1[:], tx, view.stateVersionID) + require.NoError(err) + require.Equal(accEnc, v) + + require.Zero(c.roots[view.stateVersionID].cache.Len()) + require.Zero(c.stateEvict.Len()) + return nil + }) + require.NoError(err) +} + func TestAPI(t *testing.T) { require := require.New(t) diff --git a/db/kv/membatchwithdb/memory_mutation.go b/db/kv/membatchwithdb/memory_mutation.go index 1da55903f79..ceee30d4861 100644 --- a/db/kv/membatchwithdb/memory_mutation.go +++ b/db/kv/membatchwithdb/memory_mutation.go @@ -29,8 +29,6 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" - "github.com/erigontech/erigon/db/kv/dbcfg" - "github.com/erigontech/erigon/db/kv/mdbx" "github.com/erigontech/erigon/db/kv/order" "github.com/erigontech/erigon/db/kv/stream" ) @@ -81,36 +79,6 @@ func NewMemoryBatch(tx kv.TemporalTx, tmpDir string, logger log.Logger) (*Memory }, nil } -// NewMemoryBatchMDBX creates an MDBX-backed in-memory batch. The MDBX write -// transaction pins the goroutine to an OS thread via runtime.LockOSThread(), -// so this variant must not be held across goroutine migrations. -func NewMemoryBatchMDBX(tx kv.TemporalTx, tmpDir string, logger log.Logger) (mm *MemoryMutation, err error) { - tmpDB := mdbx.New(dbcfg.TemporaryDB, logger).InMem(nil, tmpDir).GrowthStep(64 * datasize.MB).MapSize(512 * datasize.GB).MustOpen() - defer func() { - if err != nil { - tmpDB.Close() - } - }() - memTx, err := tmpDB.BeginRw(context.Background()) // nolint:gocritic - if err != nil { - return nil, fmt.Errorf("NewMemoryBatchMDBX: begin tx: %w", err) - } - if err = initSequences(tx, memTx); err != nil { - memTx.Rollback() - return nil, fmt.Errorf("NewMemoryBatchMDBX: init sequences: %w", err) - } - - return &MemoryMutation{ - mu: &sync.RWMutex{}, - db: tx, - memDb: tmpDB, - memTx: memTx, - deletedEntries: make(map[string]map[string]struct{}), - deletedDups: map[string]map[string]map[string]struct{}{}, - clearedTables: make(map[string]struct{}), - }, nil -} - func (m *MemoryMutation) UnderlyingTx() kv.TemporalTx { return m.db } @@ -1053,11 +1021,11 @@ func (m *MemoryMutation) NewReadView(tx kv.Tx) kv.TemporalTx { // *MemoryMutation. Used by NewTemporalReadView which needs to embed it. func (m *MemoryMutation) newReadViewMut(tx kv.Tx) *MemoryMutation { // Read views stay safe under a concurrent parent Close only because the - // pure-Go memStore's Rollback/Close are no-ops on its data; an MDBX-backed + // pure-Go memStore's Rollback/Close are no-ops on its data; a real-DB-backed // memTx would invalidate cursors mid-iteration. Relaxing this assertion // requires refcount/drain logic at the parent's Close. if _, ok := m.memTx.(*memStore); !ok { - panic(fmt.Sprintf("MemoryMutation.newReadViewMut: shared-tx read views require pure-Go memStore backing; got %T (use NewMemoryBatch, not NewMemoryBatchMDBX)", m.memTx)) + panic(fmt.Sprintf("MemoryMutation.newReadViewMut: shared-tx read views require pure-Go memStore backing; got %T (use NewMemoryBatch)", m.memTx)) } var dbTx kv.TemporalTx if t, ok := tx.(kv.TemporalTx); ok { diff --git a/docs/site/docs/fundamentals/modules/rpc-daemon.md b/docs/site/docs/fundamentals/modules/rpc-daemon.md index 378f50abc05..7bcb993968f 100644 --- a/docs/site/docs/fundamentals/modules/rpc-daemon.md +++ b/docs/site/docs/fundamentals/modules/rpc-daemon.md @@ -117,7 +117,7 @@ Flags: --rpc.txfeecap float Sets a cap on transaction fee (in ether) that can be sent via the RPC APIs (0 = no cap) (default 1) --socket.enabled Enable IPC server --socket.url string IPC server listening url. prefix supported are tcp, unix (default "unix:///var/run/erigon.sock") - --state.cache string Amount of data to store in the version-keyed StateCache. Set 0 to fall back to a non-versioned last-block cache (default "128MB") + --state.cache string Amount of data to store in the version-keyed StateCache (an equally-sized code cache is budgeted on top). Set 0 to fall back to a non-versioned last-block cache (default "128MB") --tls.cacert string CA certificate for client side TLS handshake for GRPC --tls.cert string certificate for client side TLS handshake for GRPC --tls.key string key file for client side TLS handshake for GRPC diff --git a/docs/site/static/llms-full.txt b/docs/site/static/llms-full.txt index eca381d36ec..8ef45da2256 100644 --- a/docs/site/static/llms-full.txt +++ b/docs/site/static/llms-full.txt @@ -4070,7 +4070,7 @@ Flags: --rpc.txfeecap float Sets a cap on transaction fee (in ether) that can be sent via the RPC APIs (0 = no cap) (default 1) --socket.enabled Enable IPC server --socket.url string IPC server listening url. prefix supported are tcp, unix (default "unix:///var/run/erigon.sock") - --state.cache string Amount of data to store in the version-keyed StateCache. Set 0 to fall back to a non-versioned last-block cache (default "128MB") + --state.cache string Amount of data to store in the version-keyed StateCache (an equally-sized code cache is budgeted on top). Set 0 to fall back to a non-versioned last-block cache (default "128MB") --tls.cacert string CA certificate for client side TLS handshake for GRPC --tls.cert string certificate for client side TLS handshake for GRPC --tls.key string key file for client side TLS handshake for GRPC diff --git a/execution/engineapi/engine_block_downloader/block_downloader.go b/execution/engineapi/engine_block_downloader/block_downloader.go index 96efb4efad1..94ff785b57f 100644 --- a/execution/engineapi/engine_block_downloader/block_downloader.go +++ b/execution/engineapi/engine_block_downloader/block_downloader.go @@ -271,23 +271,32 @@ func (e *EngineBlockDownloader) downloadBlocks(ctx context.Context, req Backward return nil } -func (e *EngineBlockDownloader) execDownloadedBatch(ctx context.Context, block *types.Block, requested common.Hash) error { - status, validationErr, lastValidHash, err := e.chainRW.ValidateChain(ctx, block.Hash(), block.NumberU64()) - if err != nil { - return err - } - // A background FCU commit briefly holds the exec semaphore; wait it out - // instead of failing the batch. - for status == execmodule.ExecutionStatusBusy { +// retryBusy re-invokes call while it reports ExecutionStatusBusy (a background +// FCU commit briefly holds the exec semaphore), polling every 50ms and logging +// periodically so a stuck commit surfaces instead of hanging silently. +func (e *EngineBlockDownloader) retryBusy(ctx context.Context, label string, call func() (execmodule.ExecutionStatus, *string, common.Hash, error)) (execmodule.ExecutionStatus, *string, common.Hash, error) { + status, validationErr, lastValidHash, err := call() + logEvery := time.NewTicker(5 * time.Second) + defer logEvery.Stop() + for err == nil && status == execmodule.ExecutionStatusBusy { select { case <-ctx.Done(): - return ctx.Err() + return status, validationErr, lastValidHash, ctx.Err() + case <-logEvery.C: + e.logger.Debug("[EngineBlockDownloader] execution busy - retrying", "label", label) case <-time.After(50 * time.Millisecond): } - status, _, lastValidHash, err = e.chainRW.ValidateChain(ctx, block.Hash(), block.NumberU64()) - if err != nil { - return err - } + status, validationErr, lastValidHash, err = call() + } + return status, validationErr, lastValidHash, err +} + +func (e *EngineBlockDownloader) execDownloadedBatch(ctx context.Context, block *types.Block, requested common.Hash) error { + status, validationErr, lastValidHash, err := e.retryBusy(ctx, "ValidateChain", func() (execmodule.ExecutionStatus, *string, common.Hash, error) { + return e.chainRW.ValidateChain(ctx, block.Hash(), block.NumberU64()) + }) + if err != nil { + return err } switch status { case execmodule.ExecutionStatusBadBlock: @@ -310,21 +319,12 @@ func (e *EngineBlockDownloader) execDownloadedBatch(ctx context.Context, block * lastValidHash, ) } - fcuStatus, _, lastValidHash, err := e.chainRW.UpdateForkChoice(ctx, block.Hash(), common.Hash{}, common.Hash{}, 0) + fcuStatus, _, lastValidHash, err := e.retryBusy(ctx, "UpdateForkChoice", func() (execmodule.ExecutionStatus, *string, common.Hash, error) { + return e.chainRW.UpdateForkChoice(ctx, block.Hash(), common.Hash{}, common.Hash{}, 0) + }) if err != nil { return err } - for fcuStatus == execmodule.ExecutionStatusBusy { - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(50 * time.Millisecond): - } - fcuStatus, _, lastValidHash, err = e.chainRW.UpdateForkChoice(ctx, block.Hash(), common.Hash{}, common.Hash{}, 0) - if err != nil { - return err - } - } if fcuStatus != execmodule.ExecutionStatusSuccess { return fmt.Errorf( "unsuccessful status when updating fork choice for batch download: status=%s, tip=%s, latestValidHash=%s", diff --git a/execution/execmodule/exec_module_test.go b/execution/execmodule/exec_module_test.go index ea1d2a0ca99..ba68a83e7ba 100644 --- a/execution/execmodule/exec_module_test.go +++ b/execution/execmodule/exec_module_test.go @@ -32,6 +32,7 @@ import ( "github.com/cenkalti/backoff/v4" "github.com/holiman/uint256" "github.com/stretchr/testify/require" + "google.golang.org/grpc" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/crypto" @@ -52,6 +53,7 @@ import ( "github.com/erigontech/erigon/execution/state/contracts" "github.com/erigontech/erigon/execution/tests/blockgen" "github.com/erigontech/erigon/execution/types" + "github.com/erigontech/erigon/node/gointerfaces/remoteproto" "github.com/erigontech/erigon/node/gointerfaces/txpoolproto" ) @@ -1321,6 +1323,56 @@ func TestAssembleBlockAmsterdamForkTransition(t *testing.T) { require.NoError(t, err) } +// TestStateChangeVersionMatchesCommitted pins the contract the Coherent kvcache +// relies on: the StateVersionId announced in a state-change batch equals the +// PlainStateVersion a committed read tx observes once that batch's commit lands. +// If they diverge, version-keyed cache roots never match any reader. +func TestStateChangeVersionMatchesCommitted(t *testing.T) { + for _, mode := range []struct { + name string + opts []execmoduletester.Option + }{ + {name: "fg-commit"}, + {name: "bg-commit", opts: []execmoduletester.Option{execmoduletester.WithFcuBackgroundCommit()}}, + } { + t.Run(mode.name, func(t *testing.T) { + ctx := t.Context() + m := execmoduletester.New(t, mode.opts...) + exec := m.ExecModule + + streamCtx, cancel := context.WithCancel(ctx) + defer cancel() + stream, err := m.StateChangesClient().StateChanges(streamCtx, &remoteproto.StateChangeRequest{}, grpc.WaitForReady(true)) + require.NoError(t, err) + + chainPack, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, 3, nil) + require.NoError(t, err) + require.NoError(t, insertValidateAndUfc1By1(ctx, exec, chainPack.Blocks)) + + topBlock := chainPack.TopBlock.NumberU64() + var lastAnnounced uint64 + for found := false; !found; { + batch, err := stream.Recv() + require.NoError(t, err) + for _, cb := range batch.ChangeBatch { + if cb.Direction == remoteproto.Direction_FORWARD && cb.BlockHeight == topBlock { + lastAnnounced = batch.StateVersionId + found = true + } + } + } + + exec.WaitIdle(ctx) + var committed uint64 + require.NoError(t, m.DB.View(ctx, func(tx kv.Tx) error { + committed, err = rawdb.GetStateVersion(tx) + return err + })) + require.Equal(t, committed, lastAnnounced, "announced StateVersionId must equal committed PlainStateVersion") + }) + } +} + // TestNotificationDispatchForegroundCommit verifies that after FCU returns // Success with the default foreground commit path: // 1. Header notifications have been dispatched (subscribers receive them) @@ -1364,8 +1416,9 @@ func TestNotificationDispatchForegroundCommit(t *testing.T) { // (see updateForkChoice / runPostForkchoice): the bg goroutine releases // the semaphore only after Flush+Commit, so FCU N+1 always reads the // committed state of FCU N. This test exercises one genesis → block 1 -// transition; multi-block coverage lives in TestNotificationDispatchForegroundCommit -// and the integration suites. +// transition; multi-block bg-commit coverage lives in +// TestReorgBackAndForwardIntoCanonicalChain (bg-commit mode) and +// TestInsertBlocksWithBatchedFCU_BadBlockRecovery_Background. func TestNotificationDispatchBackgroundCommit(t *testing.T) { m := execmoduletester.New(t, execmoduletester.WithFcuBackgroundCommit()) diff --git a/execution/execmodule/execmoduletester/exec_module_tester.go b/execution/execmodule/execmoduletester/exec_module_tester.go index 18c3096148c..88b93eb1a65 100644 --- a/execution/execmodule/execmoduletester/exec_module_tester.go +++ b/execution/execmodule/execmoduletester/exec_module_tester.go @@ -791,6 +791,8 @@ func (emt *ExecModuleTester) EnableLogs() { func (emt *ExecModuleTester) Cfg() ethconfig.Config { return emt.cfg } +func (emt *ExecModuleTester) StateChangesClient() StateChangesClient { return emt.stateChangesClient } + func (emt *ExecModuleTester) insertPoSBlocks(chain *blockgen.ChainPack) error { wr := chainreader.NewChainReaderEth1(emt.ChainConfig, emt.ExecModule, time.Hour) @@ -828,9 +830,9 @@ func (emt *ExecModuleTester) insertPoSBlocks(chain *blockgen.ChainPack) error { return fmt.Errorf("insertion failed for block %d, code: %s", chain.Blocks[chain.Length()-1].NumberU64(), status.String()) } - // UpdateForkChoice calls commit asyncronously so we need to - // wait for confimation that the headers are processed before - // returning to the caller + // Wait for the state-change dispatcher to fire for all inserted blocks. + // This only confirms dispatch — commit completion is ensured separately + // (WaitIdle in InsertChain). lastSeenBlock := chain.Headers[0].Number.Uint64() for len(insertedBlocks) > 0 { diff --git a/execution/execmodule/forkchoice.go b/execution/execmodule/forkchoice.go index b266451e159..8d42f99dfb9 100644 --- a/execution/execmodule/forkchoice.go +++ b/execution/execmodule/forkchoice.go @@ -819,6 +819,7 @@ func (e *ExecModule) dispatchNotificationsFromOverlay(sd *execctx.SharedDomains, finishProgressBefore, finishProgressAfter, e.pipelineExecutor.Sync().PrevUnwindPoint(), + true, // pre-commit: the overlay's flush+commit runs after this dispatch ); err != nil { return err } diff --git a/execution/execmodule/notification_dispatcher.go b/execution/execmodule/notification_dispatcher.go index ddfc9929849..408366e6cf1 100644 --- a/execution/execmodule/notification_dispatcher.go +++ b/execution/execmodule/notification_dispatcher.go @@ -79,6 +79,7 @@ func NewDispatcher( // - finishProgressBefore: Finish stage progress before the sync run // - finishProgressAfter: Finish stage progress after the sync run // - prevUnwindPoint: previous unwind point from the pipeline (may be nil) +// - preCommit: tx is an overlay whose flush+commit has not happened yet func (d *Dispatcher) Dispatch( ctx context.Context, tx kv.Tx, @@ -87,6 +88,7 @@ func (d *Dispatcher) Dispatch( finishProgressBefore uint64, finishProgressAfter uint64, prevUnwindPoint *uint64, + preCommit bool, ) error { // Update the accumulator with the current plain state version so downstream // consumers (e.g. state cache) know state has moved on. @@ -95,6 +97,12 @@ func (d *Dispatcher) Dispatch( if err != nil { return err } + if preCommit { + // The flush that follows bumps PlainStateVersion exactly once + // (TemporalMemBatch.flushLocked); announce the post-commit value so + // version-keyed caches match what committed readers will observe. + plainStateVersion++ + } accumulator.SetStateID(plainStateVersion) } diff --git a/execution/stagedsync/stageloop/stageloop.go b/execution/stagedsync/stageloop/stageloop.go index e4e85a4ac7e..a508d18678f 100644 --- a/execution/stagedsync/stageloop/stageloop.go +++ b/execution/stagedsync/stageloop/stageloop.go @@ -53,7 +53,7 @@ import ( // an implementation defined in another package (e.g. execmodule.Dispatcher) // without creating a circular import. type NotificationSender interface { - Dispatch(ctx context.Context, tx kv.Tx, accumulator *shards.Accumulator, recentReceipts *shards.RecentReceipts, finishProgressBefore, finishProgressAfter uint64, prevUnwindPoint *uint64) error + Dispatch(ctx context.Context, tx kv.Tx, accumulator *shards.Accumulator, recentReceipts *shards.RecentReceipts, finishProgressBefore, finishProgressAfter uint64, prevUnwindPoint *uint64, preCommit bool) error } type Hook struct { @@ -121,8 +121,8 @@ func (h *Hook) BeforeRun(tx kv.Tx, inSync bool) error { } // SendNotifications dispatches all pending notifications (state changes, -// headers, logs, receipts) via the Dispatcher. The tx is the data source — -// either the SD's blockOverlay (pre-commit) or a committed DB tx. +// headers, logs, receipts) via the Dispatcher. The tx must be a committed DB +// tx (pre-commit overlay dispatch goes through the Dispatcher directly). // // All call sites follow the same pattern: // @@ -146,6 +146,7 @@ func (h *Hook) SendNotifications(tx kv.Tx, finishProgressBefore uint64) error { finishProgressBefore, finishStageAfterSync, h.sync.PrevUnwindPoint(), + false, ) } diff --git a/llms-full.txt b/llms-full.txt index eca381d36ec..8ef45da2256 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -4070,7 +4070,7 @@ Flags: --rpc.txfeecap float Sets a cap on transaction fee (in ether) that can be sent via the RPC APIs (0 = no cap) (default 1) --socket.enabled Enable IPC server --socket.url string IPC server listening url. prefix supported are tcp, unix (default "unix:///var/run/erigon.sock") - --state.cache string Amount of data to store in the version-keyed StateCache. Set 0 to fall back to a non-versioned last-block cache (default "128MB") + --state.cache string Amount of data to store in the version-keyed StateCache (an equally-sized code cache is budgeted on top). Set 0 to fall back to a non-versioned last-block cache (default "128MB") --tls.cacert string CA certificate for client side TLS handshake for GRPC --tls.cert string certificate for client side TLS handshake for GRPC --tls.key string key file for client side TLS handshake for GRPC diff --git a/node/ethconfig/config.go b/node/ethconfig/config.go index 1e77c1866e0..78db4d8dd96 100644 --- a/node/ethconfig/config.go +++ b/node/ethconfig/config.go @@ -115,9 +115,8 @@ var Defaults = Config{ FcuBackgroundPrune: true, // FcuBackgroundCommit returns the FCU response before the MDBX commit // lands; the commit runs in a background goroutine and successive FCUs are - // serialized by the ExecModule semaphore. Until the SD-aware temporal view - // (https://github.com/erigontech/erigon/issues/21314) lands, "latest" state - // reads can lag the announced head by one block for the commit's duration. + // serialized by the ExecModule semaphore. "Latest" state reads can lag the + // announced head by one block for the commit's duration. FcuBackgroundCommit: false, ExperimentalBAL: false, WarmupKzgCtxOnInit: true, diff --git a/rpc/jsonrpc/bor_api_impl.go b/rpc/jsonrpc/bor_api_impl.go index 32754dbbc15..444f559617e 100644 --- a/rpc/jsonrpc/bor_api_impl.go +++ b/rpc/jsonrpc/bor_api_impl.go @@ -21,7 +21,6 @@ import ( "errors" "github.com/erigontech/erigon/common" - "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/execution/types" "github.com/erigontech/erigon/execution/types/accounts" "github.com/erigontech/erigon/polygon/heimdall" @@ -57,14 +56,12 @@ func (api *BorImpl) GetSnapshot(number *rpc.BlockNumber) (*Snapshot, error) { } defer tx.Rollback() - overlayTx := api.filters.WithOverlay(tx) // Retrieve the requested block number (or current if none requested) - var header *types.Header - if number == nil || *number == rpc.LatestBlockNumber { - header = rawdb.ReadCurrentHeader(overlayTx) - } else { - header, _ = api.headerByNumber(ctx, *number, tx) + blockNr := rpc.LatestBlockNumber + if number != nil { + blockNr = *number } + header, _ := api.headerByNumber(ctx, blockNr, tx) // Ensure we have an actually valid block if header == nil { return nil, errUnknownBlock @@ -162,14 +159,12 @@ func (api *BorImpl) GetSigners(number *rpc.BlockNumber) ([]common.Address, error } defer tx.Rollback() - overlayTx := api.filters.WithOverlay(tx) // Retrieve the requested block number (or current if none requested) - var header *types.Header - if number == nil || *number == rpc.LatestBlockNumber { - header = rawdb.ReadCurrentHeader(overlayTx) - } else { - header, _ = api.headerByNumber(ctx, *number, tx) + blockNr := rpc.LatestBlockNumber + if number != nil { + blockNr = *number } + header, _ := api.headerByNumber(ctx, blockNr, tx) // Ensure we have an actually valid block if header == nil { return nil, errUnknownBlock @@ -303,23 +298,13 @@ func (api *BorImpl) GetSnapshotProposer(blockNrOrHash *rpc.BlockNumberOrHash) (c } defer tx.Rollback() - overlayTx := api.filters.WithOverlay(tx) var header *types.Header - //nolint:nestif if blockNrOrHash == nil { - header = rawdb.ReadCurrentHeader(overlayTx) - } else { - if blockNr, ok := blockNrOrHash.Number(); ok { - if blockNr == rpc.LatestBlockNumber { - header = rawdb.ReadCurrentHeader(overlayTx) - } else { - header, err = api.headerByNumber(ctx, blockNr, tx) - } - } else { - if blockHash, ok := blockNrOrHash.Hash(); ok { - header, err = api.headerByHash(ctx, blockHash, tx) - } - } + header, err = api.headerByNumber(ctx, rpc.LatestBlockNumber, tx) + } else if blockNr, ok := blockNrOrHash.Number(); ok { + header, err = api.headerByNumber(ctx, blockNr, tx) + } else if blockHash, ok := blockNrOrHash.Hash(); ok { + header, err = api.headerByHash(ctx, blockHash, tx) } if header == nil || err != nil { @@ -342,23 +327,14 @@ func (api *BorImpl) GetSnapshotProposerSequence(blockNrOrHash *rpc.BlockNumberOr } defer tx.Rollback() - overlayTx := api.filters.WithOverlay(tx) // Retrieve the requested block number (or current if none requested) var header *types.Header if blockNrOrHash == nil { - header = rawdb.ReadCurrentHeader(overlayTx) - } else { - if blockNr, ok := blockNrOrHash.Number(); ok { - if blockNr == rpc.LatestBlockNumber { - header = rawdb.ReadCurrentHeader(overlayTx) - } else { - header, err = api.headerByNumber(ctx, blockNr, tx) - } - } else { - if blockHash, ok := blockNrOrHash.Hash(); ok { - header, err = api.headerByHash(ctx, blockHash, tx) - } - } + header, err = api.headerByNumber(ctx, rpc.LatestBlockNumber, tx) + } else if blockNr, ok := blockNrOrHash.Number(); ok { + header, err = api.headerByNumber(ctx, blockNr, tx) + } else if blockHash, ok := blockNrOrHash.Hash(); ok { + header, err = api.headerByHash(ctx, blockHash, tx) } // Ensure we have an actually valid block diff --git a/rpc/jsonrpc/debug_api.go b/rpc/jsonrpc/debug_api.go index 1f0420c852b..83edd2fc327 100644 --- a/rpc/jsonrpc/debug_api.go +++ b/rpc/jsonrpc/debug_api.go @@ -139,7 +139,9 @@ func (api *DebugAPIImpl) StorageRangeAt(ctx context.Context, blockHash common.Ha } blockNrOrHash := rpc.BlockNumberOrHashWithHash(blockHash, true) - blockNumber, _, _, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, api.filters) + // nil filters: resolve on the committed view — the storage-range scan reads + // temporal data through the same plain tx (see rpchelper.GetBlockNumber). + blockNumber, _, _, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, nil) if err != nil { if errors.As(err, &rpc.BlockNotFoundErr{}) { return StorageRangeResult{}, nil @@ -234,7 +236,9 @@ func (api *DebugAPIImpl) AccountRange(ctx context.Context, blockNrOrHash rpc.Blo } } else if _, ok := blockNrOrHash.Hash(); ok { - bn, _, _, err2 := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, api.filters) + // nil filters: resolve on the committed view — the dumper reads temporal + // data through the same plain tx (see rpchelper.GetBlockNumber). + bn, _, _, err2 := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, nil) if err2 != nil { return state.IteratorDump{}, err2 } @@ -633,7 +637,7 @@ func (api *DebugAPIImpl) GetRawHeader(ctx context.Context, blockNrOrHash rpc.Blo } return nil, err } - header, err := api._blockReader.Header(ctx, tx, h, n) + header, err := api._blockReader.Header(ctx, api.filters.WithOverlay(tx), h, n) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_api.go b/rpc/jsonrpc/eth_api.go index 22cc2572224..8ef56433022 100644 --- a/rpc/jsonrpc/eth_api.go +++ b/rpc/jsonrpc/eth_api.go @@ -397,7 +397,8 @@ func (api *BaseAPI) headerByHash(ctx context.Context, hash common.Hash, tx kv.Tx } } - number, err := api._blockReader.HeaderNumber(ctx, tx, hash) + overlayTx := api.filters.WithOverlay(tx) + number, err := api._blockReader.HeaderNumber(ctx, overlayTx, hash) if err != nil { return nil, err } @@ -405,7 +406,7 @@ func (api *BaseAPI) headerByHash(ctx context.Context, hash common.Hash, tx kv.Tx if number == nil { return nil, nil } - return api._blockReader.Header(ctx, tx, hash, *number) + return api._blockReader.Header(ctx, overlayTx, hash, *number) } // checks the pruning state to see if we would hold information about this diff --git a/rpc/jsonrpc/eth_call.go b/rpc/jsonrpc/eth_call.go index a06425268fb..37aec287669 100644 --- a/rpc/jsonrpc/eth_call.go +++ b/rpc/jsonrpc/eth_call.go @@ -423,7 +423,9 @@ func (api *APIImpl) GetProof(ctx context.Context, address common.Address, storag } defer roTx.Rollback() - requestedBlockNr, _, _, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, roTx, api._blockReader, api.filters) + // nil filters: resolve on the committed view — getProof gates on and reads + // the same plain roTx (see rpchelper.GetBlockNumber). + requestedBlockNr, _, _, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, roTx, api._blockReader, nil) if err != nil { return nil, err } else if requestedBlockNr == 0 { @@ -649,7 +651,9 @@ func (api *BaseAPI) getWitness(ctx context.Context, db kv.TemporalRoDB, blockNrO } defer tx.Rollback() - blockNr, hash, _, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, api.filters) // DoCall cannot be executed on non-canonical blocks + // nil filters: resolve on the committed view — the witness computation reads + // temporal data through the same plain tx (see rpchelper.GetBlockNumber). + blockNr, hash, _, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, nil) // DoCall cannot be executed on non-canonical blocks if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_simulation.go b/rpc/jsonrpc/eth_simulation.go index ef64166a5fe..b2ed2e394c5 100644 --- a/rpc/jsonrpc/eth_simulation.go +++ b/rpc/jsonrpc/eth_simulation.go @@ -125,11 +125,12 @@ func (api *APIImpl) SimulateV1(ctx context.Context, req SimulationRequest, block return nil, err } - blockNumber, blockHash, _, err := rpchelper.GetBlockNumber(ctx, blockParameter, tx, api._blockReader, api.filters) + // nil filters: resolve on the committed view — the gate and the simulator + // below read the same plain tx (see rpchelper.GetBlockNumber). + blockNumber, blockHash, _, err := rpchelper.GetBlockNumber(ctx, blockParameter, tx, api._blockReader, nil) if err != nil { return nil, err } - // Committed view: the simulator below reads state through the same plain tx. latestBlockNumber, err := rpchelper.GetLatestBlockNumber(tx) if err != nil { return nil, err diff --git a/rpc/jsonrpc/trace_filtering.go b/rpc/jsonrpc/trace_filtering.go index ffa4fcc48e9..0d4f584a282 100644 --- a/rpc/jsonrpc/trace_filtering.go +++ b/rpc/jsonrpc/trace_filtering.go @@ -346,6 +346,9 @@ func (api *TraceAPIImpl) Filter(ctx context.Context, req TraceFilterRequest, gas if err != nil { return err } + if headNumber == nil { + return errors.New("current header number not found") + } toBlock = *headNumber } else { toBlock, _, _, err = rpchelper.GetBlockNumber(ctx, *req.ToBlock, dbtx, api._blockReader, nil) diff --git a/rpc/jsonrpc/tracing.go b/rpc/jsonrpc/tracing.go index 52a9c42a93f..19d5b657e9d 100644 --- a/rpc/jsonrpc/tracing.go +++ b/rpc/jsonrpc/tracing.go @@ -61,7 +61,9 @@ func (api *DebugAPIImpl) traceBlock(ctx context.Context, blockNrOrHash rpc.Block } defer tx.Rollback() - blockNumber, hash, _, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, api.filters) + // nil filters: resolve on the committed view — the replay below reads + // temporal data through the same plain tx (see rpchelper.GetBlockNumber). + blockNumber, hash, _, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, nil) if err != nil { return err } From 3f13294b839b9ee821bdca1ab470bd8982b80481 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:50:58 +0200 Subject: [PATCH 20/45] db/kv/kvcache: key batch feeds for readers, stop cross-block root carry-over - OnNewBlock stored storage under addr+incarnation+location and code under keccak(code); readers (state.CachedReader3) look up addr+location and the account address (the E3 CodeDomain key), so batch-fed entries could never be hit. Re-key both and drop the now-unused keccak hasher. - advanceRoot no longer clones the previous canonical root: the state-change producers do not announce every mutation (account deletions, code on unwind - #22276), so inherited entries could stay stale indefinitely with no later batch to correct them. Fresh per-version roots bound any producer gap to one version, at the cost of cross-block cache reuse until #22276 lands. - Rewrite the stale header comment (the promised prevBlockHash continuity check was never implemented; describe the actual version-gap safety) and document the views' GetAsOf stubs (rpchelper.CreateHistoryCachedStateReader asserts that capability structurally). --- db/kv/kvcache/cache.go | 118 ++++++++++++---------------- db/kv/kvcache/cache_test.go | 148 +++++++++++++++++++++++------------- db/kv/kvcache/simple.go | 3 + 3 files changed, 148 insertions(+), 121 deletions(-) diff --git a/db/kv/kvcache/cache.go b/db/kv/kvcache/cache.go index be7074bdd5a..c27e064c0f2 100644 --- a/db/kv/kvcache/cache.go +++ b/db/kv/kvcache/cache.go @@ -20,16 +20,13 @@ import ( "bytes" "context" - "encoding/binary" "fmt" - "hash" "sync" "sync/atomic" "time" "github.com/c2h5oh/datasize" - keccak "github.com/erigontech/fastkeccak" btree2 "github.com/tidwall/btree" "github.com/erigontech/erigon/common" @@ -66,24 +63,19 @@ type CacheView interface { // provide "Serializable Isolation Level" semantic: all data form consistent db view at moment // when read transaction started, read data are immutable until end of read transaction, reader can't see newer updates // -// Every time a new state change comes, we do the following: -// - Check that prevBlockHeight and prevBlockHash match what is the top values we have, and if they don't we -// invalidate the cache, because we missed some messages and cannot consider the cache coherent anymore. -// - Clone the cache pointer (such that the previous pointer is still accessible, but new one shared the content with it), -// apply state updates to the cloned cache pointer and save under the new identified made from blockHeight and blockHash. -// - If there is a conditional variable corresponding to the identifier, remove it from the map and notify conditional -// variable, waking up the read-only transaction waiting on it. -// -// On the other hand, whenever we have a cache miss (by looking at the top cache), we do the following: -// - Once read the current block height and block hash (canonical) from underlying db transaction -// - Construct the identifier from the current block height and block hash -// - Look for the constructed identifier in the cache. If the identifier is found, use the corresponding -// cache in conjunction with this read-only transaction (it will be consistent with it). If the identifier is -// not found, it means that the transaction has been committed in Erigon, but the state update has not -// arrived yet (as shown in the picture on the right). Insert conditional variable for this identifier and wait on -// it until either cache with the given identifier appears, or timeout (indicating that the cache update -// mechanism is broken and cache is likely invalidated). +// Roots are keyed by PlainStateVersion. OnNewBlock creates the canonical root +// for the announced version from that batch's changes alone; a reader whose +// version has no root yet waits up to NewBlockWait for the batch, then +// proceeds uncached. On a cache miss the reader consults its own transaction +// and, when its version is the latest known one, inserts the result for other +// same-version readers. // +// A canonical root deliberately does not inherit entries from its predecessor: +// the state-change producers do not announce every mutation (see +// https://github.com/erigontech/erigon/issues/22276), so carried entries could +// go stale with no later batch to correct them. Fresh roots bound any producer +// gap to one version — and a missed batch only costs cache warmth, never +// coherency, because the version gap simply leaves that root unfed. // Pair.Value == nil - is a marker of absense key in db @@ -93,16 +85,13 @@ type CacheView interface { // - CacheView is always coherent with given db transaction - // // Rules of set view.isCanonical value: -// - method View can't parent.Clone() - because parent view is not coherent with current kv.Tx -// - only OnNewBlock method may do parent.Clone() and apply StateChanges to create coherent view of kv.Tx -// - parent.Clone() can't be called if parent.isCanonical=false -// - only OnNewBlock method can set view.isCanonical=true +// - only OnNewBlock method can set view.isCanonical=true (from StateChanges) +// - roots created by View (readers ahead of their batch) stay non-canonical // // Rules of filling cache.stateEvict: // - changes in Canonical View SHOULD reflect in stateEvict // - changes in Non-Canonical View SHOULD NOT reflect in stateEvict type Coherent struct { - hasher hash.Hash codeEvictLen metrics.Gauge codeKeys metrics.Gauge keys metrics.Gauge @@ -142,6 +131,9 @@ type CoherentView struct { func (c *CoherentView) Get(k []byte) ([]byte, error) { return c.cache.Get(k, c.tx, c.stateVersionID) } + +// GetAsOf satisfies the optional capability rpchelper.CreateHistoryCachedStateReader +// asserts; the cache holds latest-state only, so historical reads always fall through. func (c *CoherentView) GetAsOf(key []byte, ts uint64) (v []byte, ok bool, err error) { return nil, false, nil } @@ -198,7 +190,6 @@ func New(cfg CoherentConfig) *Coherent { roots: map[uint64]*CoherentRoot{}, stateEvict: &ThreadSafeEvictionList{l: NewList()}, codeEvict: &ThreadSafeEvictionList{l: NewList()}, - hasher: keccak.NewFastKeccak(), cfg: cfg, miss: metrics.GetOrCreateCounter(fmt.Sprintf(`cache_total{result="miss",name="%s"}`, cfg.MetricsLabel)), hits: metrics.GetOrCreateCounter(fmt.Sprintf(`cache_total{result="hit",name="%s"}`, cfg.MetricsLabel)), @@ -244,31 +235,29 @@ func (c *Coherent) advanceRoot(stateVersionID uint64) (r *CoherentRoot) { c.roots[stateVersionID] = r } - if prevView, ok := c.roots[stateVersionID-1]; ok && prevView.isCanonical { - //log.Info("advance: clone", "from", viewID-1, "to", viewID) - r.cache = prevView.cache.Copy() - r.codeCache = prevView.codeCache.Copy() + // No carry-over from the previous canonical root: the state-change + // producers don't announce every mutation (account deletions, code on + // unwind — https://github.com/erigontech/erigon/issues/22276), so + // inherited entries could stay stale forever. Fresh roots bound any + // producer gap to one version. + c.stateEvict.Init() + c.codeEvict.Init() + if r.cache == nil { + r.cache = btree2.NewBTreeG(Less) + r.codeCache = btree2.NewBTreeG(Less) } else { - c.stateEvict.Init() - c.codeEvict.Init() - if r.cache == nil { - //log.Info("advance: new", "to", viewID) - r.cache = btree2.NewBTreeG(Less) - r.codeCache = btree2.NewBTreeG(Less) - } else { - r.cache.Walk(func(items []*Element) bool { - for _, i := range items { - c.stateEvict.PushFront(i) - } - return true - }) - r.codeCache.Walk(func(items []*Element) bool { - for _, i := range items { - c.codeEvict.PushFront(i) - } - return true - }) - } + r.cache.Walk(func(items []*Element) bool { + for _, i := range items { + c.stateEvict.PushFront(i) + } + return true + }) + r.codeCache.Walk(func(items []*Element) bool { + for _, i := range items { + c.codeEvict.PushFront(i) + } + return true + }) } r.isCanonical = true @@ -292,40 +281,31 @@ func (c *Coherent) OnNewBlock(stateChanges *remoteproto.StateChangeBatch) { for _, sc := range stateChanges.ChangeBatch { for i := range sc.Changes { + // Code and storage keys must match what readers look up: code is + // keyed by account address (the E3 CodeDomain key) and storage by + // address+location — see state.CachedReader3. + addr := gointerfaces.ConvertH160toAddress(sc.Changes[i].Address) switch sc.Changes[i].Action { case remoteproto.Action_UPSERT: - addr := gointerfaces.ConvertH160toAddress(sc.Changes[i].Address) - v := sc.Changes[i].Data - c.add(addr[:], v, r, id) + c.add(addr[:], sc.Changes[i].Data, r, id) case remoteproto.Action_UPSERT_CODE: - addr := gointerfaces.ConvertH160toAddress(sc.Changes[i].Address) - v := sc.Changes[i].Data - c.add(addr[:], v, r, id) - c.hasher.Reset() - c.hasher.Write(sc.Changes[i].Code) - k := c.hasher.Sum(nil) - c.addCode(k, sc.Changes[i].Code, r, id) + c.add(addr[:], sc.Changes[i].Data, r, id) + c.addCode(addr[:], sc.Changes[i].Code, r, id) case remoteproto.Action_REMOVE: - addr := gointerfaces.ConvertH160toAddress(sc.Changes[i].Address) c.add(addr[:], nil, r, id) case remoteproto.Action_STORAGE: //skip, will check later case remoteproto.Action_CODE: - c.hasher.Reset() - c.hasher.Write(sc.Changes[i].Code) - k := c.hasher.Sum(nil) - c.addCode(k, sc.Changes[i].Code, r, id) + c.addCode(addr[:], sc.Changes[i].Code, r, id) default: panic("not implemented yet") } if c.cfg.WithStorage && len(sc.Changes[i].StorageChanges) > 0 { - addr := gointerfaces.ConvertH160toAddress(sc.Changes[i].Address) for _, change := range sc.Changes[i].StorageChanges { loc := gointerfaces.ConvertH256ToHash(change.Location) - k := make([]byte, 20+8+32) + k := make([]byte, 20+32) copy(k, addr[:]) - binary.BigEndian.PutUint64(k[20:], sc.Changes[i].Incarnation) - copy(k[20+8:], loc[:]) + copy(k[20:], loc[:]) c.add(k, change.Data, r, id) } } diff --git a/db/kv/kvcache/cache_test.go b/db/kv/kvcache/cache_test.go index 520b79377ef..987c52cf924 100644 --- a/db/kv/kvcache/cache_test.go +++ b/db/kv/kvcache/cache_test.go @@ -25,7 +25,6 @@ import ( "testing" "time" - keccak "github.com/erigontech/fastkeccak" "github.com/holiman/uint256" "github.com/stretchr/testify/require" @@ -169,6 +168,102 @@ func TestEviction(t *testing.T) { require.Equal(int(cfg.CacheSize.Bytes()), c.stateEvict.Size()) } +// Canonical roots must start from their own batch only: the state-change +// producers do not announce every mutation (e.g. account deletions), so +// entries inherited from the previous root could stay stale forever. +func TestCanonicalRootsStartFresh(t *testing.T) { + require := require.New(t) + cfg := DefaultCoherentConfig + cfg.NewBlockWait = 0 + c := New(cfg) + + k1 := [20]byte{1} + c.OnNewBlock(&remoteproto.StateChangeBatch{ + StateVersionId: 2, + ChangeBatch: []*remoteproto.StateChange{{ + Direction: remoteproto.Direction_FORWARD, + Changes: []*remoteproto.AccountChange{{ + Action: remoteproto.Action_UPSERT, + Address: gointerfaces.ConvertAddressToH160(k1), + Data: []byte{1}, + }}, + }}, + }) + require.Equal(1, c.roots[2].cache.Len()) + + c.OnNewBlock(&remoteproto.StateChangeBatch{StateVersionId: 3}) + require.True(c.roots[3].isCanonical) + require.Zero(c.roots[3].cache.Len()) +} + +// Batch-fed storage entries must be stored under the key shape readers use: +// address+location (see state.CachedReader3.ReadAccountStorage). +func TestOnNewBlockStorageKeysMatchReaders(t *testing.T) { + require, ctx := require.New(t), t.Context() + cfg := DefaultCoherentConfig + cfg.NewBlockWait = 0 + c := New(cfg) + db := temporaltest.NewTestDB(t, datadir.New(t.TempDir())) + + addr, loc := [20]byte{1}, [32]byte{2} + c.OnNewBlock(&remoteproto.StateChangeBatch{ + StateVersionId: 2, + ChangeBatch: []*remoteproto.StateChange{{ + Direction: remoteproto.Direction_FORWARD, + Changes: []*remoteproto.AccountChange{{ + Action: remoteproto.Action_STORAGE, + Address: gointerfaces.ConvertAddressToH160(addr), + StorageChanges: []*remoteproto.StorageChange{{ + Location: gointerfaces.ConvertHashToH256(loc), + Data: []byte{42}, + }}, + }}, + }}, + }) + + err := db.ViewTemporal(ctx, func(tx kv.TemporalTx) error { + k := append(addr[:], loc[:]...) + v, err := c.Get(k, tx, 2) + require.NoError(err) + require.Equal([]byte{42}, v) + return nil + }) + require.NoError(err) +} + +// Batch-fed code entries must be stored under the key shape readers use: +// the account address, which is the E3 CodeDomain key +// (see state.CachedReader3.ReadAccountCode). +func TestOnNewBlockCodeKeysMatchReaders(t *testing.T) { + require, ctx := require.New(t), t.Context() + cfg := DefaultCoherentConfig + cfg.NewBlockWait = 0 + c := New(cfg) + db := temporaltest.NewTestDB(t, datadir.New(t.TempDir())) + + addr := [20]byte{1} + code := []byte{0x60, 0x60} + c.OnNewBlock(&remoteproto.StateChangeBatch{ + StateVersionId: 2, + ChangeBatch: []*remoteproto.StateChange{{ + Direction: remoteproto.Direction_FORWARD, + Changes: []*remoteproto.AccountChange{{ + Action: remoteproto.Action_CODE, + Address: gointerfaces.ConvertAddressToH160(addr), + Code: code, + }}, + }}, + }) + + err := db.ViewTemporal(ctx, func(tx kv.TemporalTx) error { + v, err := c.GetCode(addr[:], tx, 2) + require.NoError(err) + require.Equal(code, v) + return nil + }) + require.NoError(err) +} + // A request whose cache view outlives KeepViews state-version advances (e.g. a // long eth_call) must fall back to its own tx snapshot, not error out. func TestViewSurvivesRootEviction(t *testing.T) { @@ -541,57 +636,6 @@ func TestAPI(t *testing.T) { } } -func TestOnNewBlockCodeHashKey(t *testing.T) { - require := require.New(t) - cfg := DefaultCoherentConfig - cfg.NewBlockWait = 0 - c := New(cfg) - - code := []byte{0x01, 0x02, 0x03, 0x04} - addr := common.Address{0xAA} - - batch := &remoteproto.StateChangeBatch{ - StateVersionId: 1, - ChangeBatch: []*remoteproto.StateChange{ - { - Direction: remoteproto.Direction_FORWARD, - Changes: []*remoteproto.AccountChange{ - { - Action: remoteproto.Action_CODE, - Address: gointerfaces.ConvertAddressToH160(addr), - Code: code, - }, - }, - }, - }, - } - - c.OnNewBlock(batch) - - c.lock.Lock() - defer c.lock.Unlock() - - require.NotNil(c.latestStateView) - require.Equal(uint64(1), c.latestStateVersionID) - - var elems []*Element - c.latestStateView.codeCache.Walk(func(items []*Element) bool { - if len(items) > 0 { - elems = append(elems, items...) - } - return true - }) - - require.Len(elems, 1) - - h := keccak.NewFastKeccak() - h.Write(code) - expectedKey := h.Sum(nil) - - require.Equal(expectedKey, elems[0].K) - require.Equal(code, elems[0].V) -} - func TestCode(t *testing.T) { require, ctx := require.New(t), t.Context() c := New(DefaultCoherentConfig) diff --git a/db/kv/kvcache/simple.go b/db/kv/kvcache/simple.go index 205e6ef0c46..bc5433d5750 100644 --- a/db/kv/kvcache/simple.go +++ b/db/kv/kvcache/simple.go @@ -105,6 +105,9 @@ type SimpleView struct { } func (c *SimpleView) Get(k []byte) ([]byte, error) { return c.cache.Get(k, c.tx, 0) } + +// GetAsOf satisfies the optional capability rpchelper.CreateHistoryCachedStateReader +// asserts; the cache holds latest-state only, so historical reads always fall through. func (c *SimpleView) GetAsOf(key []byte, ts uint64) (v []byte, ok bool, err error) { return nil, false, nil } From f85cc6c49782672e3949d0f144e6513a1ab37d5b Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:55:36 +0200 Subject: [PATCH 21/45] cmd/rpcdaemon, docs: keep --state.cache default at 0MB The flip to 128MB (Coherent by default) moves to the stacked PR #22269 together with the FcuBackgroundCommit default flip; this PR keeps the groundwork only (the with-datadir path honors the flag, and the Coherent cache fixes make a non-zero setting work as designed). --- cmd/rpcdaemon/cli/config.go | 2 +- docs/site/docs/fundamentals/modules/rpc-daemon.md | 2 +- docs/site/static/llms-full.txt | 2 +- llms-full.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/rpcdaemon/cli/config.go b/cmd/rpcdaemon/cli/config.go index a03ad468491..1bb3e10392d 100644 --- a/cmd/rpcdaemon/cli/config.go +++ b/cmd/rpcdaemon/cli/config.go @@ -137,7 +137,7 @@ func RootCommand() (*cobra.Command, *httpcfg.HttpCfg) { rootCmd.PersistentFlags().BoolVar(&cfg.GethCompatibility, "rpc.gethcompat", false, "Enables Geth-compatible storage iteration order for debug_storageRangeAt (sorted by keccak256 hash). Disabled by default for performance.") rootCmd.PersistentFlags().StringVar(&cfg.TxPoolApiAddr, "txpool.api.addr", "", "txpool api network address, for example: 127.0.0.1:9090 (default: use value of --private.api.addr)") - rootCmd.PersistentFlags().StringVar(&stateCacheStr, "state.cache", "128MB", "Amount of data to store in the version-keyed StateCache (an equally-sized code cache is budgeted on top). Set 0 to fall back to a non-versioned last-block cache") + rootCmd.PersistentFlags().StringVar(&stateCacheStr, "state.cache", "0MB", "Amount of data to store in the version-keyed StateCache (an equally-sized code cache is budgeted on top). Set 0 to fall back to a non-versioned last-block cache") rootCmd.PersistentFlags().BoolVar(&cfg.GRPCServerEnabled, "grpc", false, "Enable GRPC server") rootCmd.PersistentFlags().StringVar(&cfg.GRPCListenAddress, "grpc.addr", nodecfg.DefaultGRPCHost, "GRPC server listening interface") rootCmd.PersistentFlags().IntVar(&cfg.GRPCPort, "grpc.port", nodecfg.DefaultGRPCPort, "GRPC server listening port") diff --git a/docs/site/docs/fundamentals/modules/rpc-daemon.md b/docs/site/docs/fundamentals/modules/rpc-daemon.md index 7bcb993968f..ab0580e362c 100644 --- a/docs/site/docs/fundamentals/modules/rpc-daemon.md +++ b/docs/site/docs/fundamentals/modules/rpc-daemon.md @@ -117,7 +117,7 @@ Flags: --rpc.txfeecap float Sets a cap on transaction fee (in ether) that can be sent via the RPC APIs (0 = no cap) (default 1) --socket.enabled Enable IPC server --socket.url string IPC server listening url. prefix supported are tcp, unix (default "unix:///var/run/erigon.sock") - --state.cache string Amount of data to store in the version-keyed StateCache (an equally-sized code cache is budgeted on top). Set 0 to fall back to a non-versioned last-block cache (default "128MB") + --state.cache string Amount of data to store in the version-keyed StateCache (an equally-sized code cache is budgeted on top). Set 0 to fall back to a non-versioned last-block cache (default "0MB") --tls.cacert string CA certificate for client side TLS handshake for GRPC --tls.cert string certificate for client side TLS handshake for GRPC --tls.key string key file for client side TLS handshake for GRPC diff --git a/docs/site/static/llms-full.txt b/docs/site/static/llms-full.txt index 8ef45da2256..7a83c1bcba8 100644 --- a/docs/site/static/llms-full.txt +++ b/docs/site/static/llms-full.txt @@ -4070,7 +4070,7 @@ Flags: --rpc.txfeecap float Sets a cap on transaction fee (in ether) that can be sent via the RPC APIs (0 = no cap) (default 1) --socket.enabled Enable IPC server --socket.url string IPC server listening url. prefix supported are tcp, unix (default "unix:///var/run/erigon.sock") - --state.cache string Amount of data to store in the version-keyed StateCache (an equally-sized code cache is budgeted on top). Set 0 to fall back to a non-versioned last-block cache (default "128MB") + --state.cache string Amount of data to store in the version-keyed StateCache (an equally-sized code cache is budgeted on top). Set 0 to fall back to a non-versioned last-block cache (default "0MB") --tls.cacert string CA certificate for client side TLS handshake for GRPC --tls.cert string certificate for client side TLS handshake for GRPC --tls.key string key file for client side TLS handshake for GRPC diff --git a/llms-full.txt b/llms-full.txt index 8ef45da2256..7a83c1bcba8 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -4070,7 +4070,7 @@ Flags: --rpc.txfeecap float Sets a cap on transaction fee (in ether) that can be sent via the RPC APIs (0 = no cap) (default 1) --socket.enabled Enable IPC server --socket.url string IPC server listening url. prefix supported are tcp, unix (default "unix:///var/run/erigon.sock") - --state.cache string Amount of data to store in the version-keyed StateCache (an equally-sized code cache is budgeted on top). Set 0 to fall back to a non-versioned last-block cache (default "128MB") + --state.cache string Amount of data to store in the version-keyed StateCache (an equally-sized code cache is budgeted on top). Set 0 to fall back to a non-versioned last-block cache (default "0MB") --tls.cacert string CA certificate for client side TLS handshake for GRPC --tls.cert string certificate for client side TLS handshake for GRPC --tls.key string key file for client side TLS handshake for GRPC From a458dd9ec08169b38f8ec17c44c74e6e8358dcf6 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:53:22 +0200 Subject: [PATCH 22/45] db/kv/kvcache: refresh codeEvict, not stateEvict, on code-domain cache hits MoveToFront's list-identity guard made the old call a silent no-op for code elements, so the code cache evicted in insertion order regardless of use. --- db/kv/kvcache/cache.go | 6 +++++- db/kv/kvcache/cache_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/db/kv/kvcache/cache.go b/db/kv/kvcache/cache.go index c27e064c0f2..2397b5b2124 100644 --- a/db/kv/kvcache/cache.go +++ b/db/kv/kvcache/cache.go @@ -369,7 +369,11 @@ func (c *Coherent) getFromCache(k []byte, id uint64, domain kv.Domain) (*Element it, _ = r.cache.Get(&Element{K: k}) } if it != nil && isLatest { - c.stateEvict.MoveToFront(it) + if domain == kv.CodeDomain { + c.codeEvict.MoveToFront(it) + } else { + c.stateEvict.MoveToFront(it) + } } return it, r } diff --git a/db/kv/kvcache/cache_test.go b/db/kv/kvcache/cache_test.go index 987c52cf924..980d6a684d0 100644 --- a/db/kv/kvcache/cache_test.go +++ b/db/kv/kvcache/cache_test.go @@ -264,6 +264,36 @@ func TestOnNewBlockCodeKeysMatchReaders(t *testing.T) { require.NoError(err) } +// A cache hit on the code domain must refresh the entry's position in the +// code eviction list — otherwise hot code is evicted in insertion order. +func TestCodeHitRefreshesCodeEvictLRU(t *testing.T) { + require, ctx := require.New(t), t.Context() + cfg := DefaultCoherentConfig + cfg.NewBlockWait = 0 + c := New(cfg) + db := temporaltest.NewTestDB(t, datadir.New(t.TempDir())) + + addr1, addr2 := [20]byte{1}, [20]byte{2} + c.OnNewBlock(&remoteproto.StateChangeBatch{ + StateVersionId: 2, + ChangeBatch: []*remoteproto.StateChange{{ + Direction: remoteproto.Direction_FORWARD, + Changes: []*remoteproto.AccountChange{ + {Action: remoteproto.Action_CODE, Address: gointerfaces.ConvertAddressToH160(addr1), Code: []byte{1}}, + {Action: remoteproto.Action_CODE, Address: gointerfaces.ConvertAddressToH160(addr2), Code: []byte{2}}, + }, + }}, + }) + require.Equal(addr1[:], c.codeEvict.Oldest().K) + + err := db.ViewTemporal(ctx, func(tx kv.TemporalTx) error { + _, err := c.GetCode(addr1[:], tx, 2) + return err + }) + require.NoError(err) + require.Equal(addr2[:], c.codeEvict.Oldest().K) +} + // A request whose cache view outlives KeepViews state-version advances (e.g. a // long eth_call) must fall back to its own tx snapshot, not error out. func TestViewSurvivesRootEviction(t *testing.T) { From 0c94527b36fa40c5def7e71b671c15e63a666dee Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 7 Jul 2026 13:04:28 +0200 Subject: [PATCH 23/45] rpc/jsonrpc, execution/execmodule: committed-view debug_accountAt, overlay-aware txcount-by-hash, version-parity test hardening - debug_accountAt resolves the block hash on the committed view (plain-tx HeaderNumber, no blocksLRU): its GetAsOf history reads see only committed data, so an overlay-published head now reads as an unknown block (null) instead of erroring "canonical hash not found" during the bg-commit window. - eth_getBlockTransactionCountByHash goes through the overlay like its by-number twin and eth_getBlockByHash, so all three agree on the in-flight head. Regression tests for both on the #22140 overlay harness, which now also writes ForkchoiceHead (mirroring production writeForkChoiceHashes). - TestStateChangeVersionMatchesCommitted gains a batched 20-block dimension (crosses the initial-cycle threshold, pinning mid-FCU CommitCycle version bumps against the announce) and a 30s deadline on the state-change stream so a missing batch fails crisply instead of hanging. --- execution/execmodule/exec_module_test.go | 92 ++++++++++++++++-------- rpc/jsonrpc/debug_api.go | 14 ++-- rpc/jsonrpc/eth_block.go | 4 +- rpc/jsonrpc/overlay_race_test.go | 34 +++++++++ 4 files changed, 106 insertions(+), 38 deletions(-) diff --git a/execution/execmodule/exec_module_test.go b/execution/execmodule/exec_module_test.go index ba68a83e7ba..bccb7eebe3a 100644 --- a/execution/execmodule/exec_module_test.go +++ b/execution/execmodule/exec_module_test.go @@ -685,6 +685,26 @@ func insertValidateAndUfc1By1(ctx context.Context, exec *execmodule.ExecModule, return nil } +// insertAndUfcBatched inserts all blocks and drives a single FCU to the top, +// so the whole batch executes under one forkchoice run. +func insertAndUfcBatched(ctx context.Context, exec *execmodule.ExecModule, blocks []*types.Block) error { + ir, err := insertBlocks(ctx, exec, blocks) + if err != nil { + return err + } + if ir != execmodule.ExecutionStatusSuccess { + return fmt.Errorf("unexpected insertBlocks status: %s", ir) + } + ur, err := updateForkChoice(ctx, exec, blocks[len(blocks)-1].Header()) + if err != nil { + return err + } + if ur.Status != execmodule.ExecutionStatusSuccess { + return fmt.Errorf("unexpected updateForkChoice status: %s", ur.Status) + } + return nil +} + func assembleBlock(ctx context.Context, exec *execmodule.ExecModule, params *builder.Parameters) (uint64, error) { return retryBusy(ctx, func() (uint64, bool, error) { r, err := exec.AssembleBlock(ctx, params) @@ -1335,41 +1355,53 @@ func TestStateChangeVersionMatchesCommitted(t *testing.T) { {name: "fg-commit"}, {name: "bg-commit", opts: []execmoduletester.Option{execmoduletester.WithFcuBackgroundCommit()}}, } { - t.Run(mode.name, func(t *testing.T) { - ctx := t.Context() - m := execmoduletester.New(t, mode.opts...) - exec := m.ExecModule - - streamCtx, cancel := context.WithCancel(ctx) - defer cancel() - stream, err := m.StateChangesClient().StateChanges(streamCtx, &remoteproto.StateChangeRequest{}, grpc.WaitForReady(true)) - require.NoError(t, err) - - chainPack, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, 3, nil) - require.NoError(t, err) - require.NoError(t, insertValidateAndUfc1By1(ctx, exec, chainPack.Blocks)) + // 1by1 flushes once per block; batched executes all blocks under one + // FCU and crosses the initial-cycle threshold, so mid-FCU CommitCycle + // commits bump the version before the single announce. + for _, ins := range []struct { + name string + blocks int + insert func(context.Context, *execmodule.ExecModule, []*types.Block) error + }{ + {name: "1by1", blocks: 3, insert: insertValidateAndUfc1By1}, + {name: "batched", blocks: 20, insert: insertAndUfcBatched}, + } { + t.Run(mode.name+"/"+ins.name, func(t *testing.T) { + ctx := t.Context() + m := execmoduletester.New(t, mode.opts...) + exec := m.ExecModule + + streamCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + stream, err := m.StateChangesClient().StateChanges(streamCtx, &remoteproto.StateChangeRequest{}, grpc.WaitForReady(true)) + require.NoError(t, err) - topBlock := chainPack.TopBlock.NumberU64() - var lastAnnounced uint64 - for found := false; !found; { - batch, err := stream.Recv() + chainPack, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, ins.blocks, nil) require.NoError(t, err) - for _, cb := range batch.ChangeBatch { - if cb.Direction == remoteproto.Direction_FORWARD && cb.BlockHeight == topBlock { - lastAnnounced = batch.StateVersionId - found = true + require.NoError(t, ins.insert(ctx, exec, chainPack.Blocks)) + + topBlock := chainPack.TopBlock.NumberU64() + var lastAnnounced uint64 + for found := false; !found; { + batch, err := stream.Recv() + require.NoError(t, err) + for _, cb := range batch.ChangeBatch { + if cb.Direction == remoteproto.Direction_FORWARD && cb.BlockHeight == topBlock { + lastAnnounced = batch.StateVersionId + found = true + } } } - } - exec.WaitIdle(ctx) - var committed uint64 - require.NoError(t, m.DB.View(ctx, func(tx kv.Tx) error { - committed, err = rawdb.GetStateVersion(tx) - return err - })) - require.Equal(t, committed, lastAnnounced, "announced StateVersionId must equal committed PlainStateVersion") - }) + exec.WaitIdle(ctx) + var committed uint64 + require.NoError(t, m.DB.View(ctx, func(tx kv.Tx) error { + committed, err = rawdb.GetStateVersion(tx) + return err + })) + require.Equal(t, committed, lastAnnounced, "announced StateVersionId must equal committed PlainStateVersion") + }) + } } } diff --git a/rpc/jsonrpc/debug_api.go b/rpc/jsonrpc/debug_api.go index 83edd2fc327..73a36a32f53 100644 --- a/rpc/jsonrpc/debug_api.go +++ b/rpc/jsonrpc/debug_api.go @@ -562,31 +562,33 @@ func (api *DebugAPIImpl) AccountAt(ctx context.Context, blockHash common.Hash, t } defer tx.Rollback() - header, err := api.headerByHash(ctx, blockHash, tx) + // Committed view: the canonical-hash check and GetAsOf reads below use the + // same plain tx (an overlay-resolved head would have no committed history). + blockNumber, err := api._blockReader.HeaderNumber(ctx, tx, blockHash) if err != nil { return &AccountResult{}, err } - if header == nil { + if blockNumber == nil { return nil, nil // not error, see https://github.com/erigontech/erigon/issues/1645 } - canonicalHash, ok, err := api._blockReader.CanonicalHash(ctx, tx, header.Number.Uint64()) + canonicalHash, ok, err := api._blockReader.CanonicalHash(ctx, tx, *blockNumber) if err != nil { return nil, err } if !ok { - return nil, fmt.Errorf("canonical hash not found %d", header.Number.Uint64()) + return nil, fmt.Errorf("canonical hash not found %d", *blockNumber) } isCanonical := canonicalHash == blockHash if !isCanonical { return nil, errors.New("block hash is not canonical") } - err = api.BaseAPI.checkPruneHistory(ctx, tx, header.Number.Uint64()) + err = api.BaseAPI.checkPruneHistory(ctx, tx, *blockNumber) if err != nil { return nil, err } - minTxNum, err := api._txNumReader.Min(ctx, tx, header.Number.Uint64()) + minTxNum, err := api._txNumReader.Min(ctx, tx, *blockNumber) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_block.go b/rpc/jsonrpc/eth_block.go index f3a9e96e203..8a7739568ee 100644 --- a/rpc/jsonrpc/eth_block.go +++ b/rpc/jsonrpc/eth_block.go @@ -400,7 +400,7 @@ func (api *APIImpl) GetBlockTransactionCountByHash(ctx context.Context, blockHas } defer tx.Rollback() - blockNum, _, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHash{BlockHash: &blockHash}, tx, api._blockReader, nil) + blockNum, _, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHash{BlockHash: &blockHash}, tx, api._blockReader, api.filters) if err != nil { // (Compatibility) Every other node just return `null` for when the block does not exist. log.Debug("eth_getBlockTransactionCountByHash GetBlockNumber failed", "err", err) @@ -412,7 +412,7 @@ func (api *APIImpl) GetBlockTransactionCountByHash(ctx context.Context, blockHas return nil, err } - _, txCount, err := api._blockReader.Body(ctx, tx, blockHash, blockNum) + _, txCount, err := api._blockReader.Body(ctx, api.filters.WithOverlay(tx), blockHash, blockNum) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/overlay_race_test.go b/rpc/jsonrpc/overlay_race_test.go index b37b3133fa1..03cdbc3cd1d 100644 --- a/rpc/jsonrpc/overlay_race_test.go +++ b/rpc/jsonrpc/overlay_race_test.go @@ -98,6 +98,7 @@ func newOverlayAheadTestAPI(t *testing.T) (base *BaseAPI, m *execmoduletester.Ex // enough for the reader paths under test to resolve this header as current. require.NoError(t, rawdb.WriteHeader(overlay, overlayHeader)) require.NoError(t, rawdb.WriteHeadHeaderHash(overlay, hash)) + rawdb.WriteForkchoiceHead(overlay, hash) require.NoError(t, rawdb.WriteCanonicalHash(overlay, hash, overlayNumber)) require.NoError(t, rawdb.WriteBody(overlay, hash, overlayNumber, &types.Body{})) @@ -211,6 +212,39 @@ func TestTxPoolContent_UsesOverlayHead(t *testing.T) { "pending tx gas price must be derived from the overlay head's base fee, not the stale MDBX head") } +// TestGetBlockTransactionCountByHash_SeesOverlayHead pins that the by-hash +// count resolves the overlay head exactly like its by-number twin: the same +// in-flight block must be visible through both, not null through one of them. +func TestGetBlockTransactionCountByHash_SeesOverlayHead(t *testing.T) { + t.Parallel() + base, m, overlayHeader := newOverlayAheadTestAPI(t) + api := newEthApiForTest(base, m.DB, nil, nil) + + byNumber, err := api.GetBlockTransactionCountByNumber(m.Ctx, rpc.BlockNumber(overlayHeader.Number.Uint64())) + require.NoError(t, err) + require.NotNil(t, byNumber) + + byHash, err := api.GetBlockTransactionCountByHash(m.Ctx, overlayHeader.Hash()) + require.NoError(t, err) + require.NotNil(t, byHash, "by-hash count must see the overlay head the by-number count sees") + require.Equal(t, *byNumber, *byHash) +} + +// TestDebugAccountAt_OverlayHeadHash_CommittedView pins that debug_accountAt +// resolves the block hash on the committed view: its GetAsOf history reads can +// only see committed data, so an overlay-published head must read as an +// unknown block (null) — not resolve to a header whose canonical-hash check +// then fails. +func TestDebugAccountAt_OverlayHeadHash_CommittedView(t *testing.T) { + t.Parallel() + base, m, overlayHeader := newOverlayAheadTestAPI(t) + api := NewPrivateDebugAPI(base, m.DB, nil, 0, false) + + result, err := api.AccountAt(m.Ctx, overlayHeader.Hash(), 0, m.Address) + require.NoError(t, err, "an in-flight (uncommitted) head hash must read as unknown, not error") + require.Nil(t, result) +} + // TestTxPoolContentFrom_UsesOverlayHead pins that txpool_contentFrom reads the // current header through the block overlay, matching TestTxPoolContent_UsesOverlayHead. func TestTxPoolContentFrom_UsesOverlayHead(t *testing.T) { From 5b052eb2dd8da948bb61ae54b8d3097660c9a255 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 7 Jul 2026 13:20:16 +0200 Subject: [PATCH 24/45] rpc/jsonrpc: return nil result on error in debug_accountAt Pre-existing shape kept in 0c94527b36; every other error path in the function returns nil, and the RPC handler discards the result on error anyway. --- rpc/jsonrpc/debug_api.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rpc/jsonrpc/debug_api.go b/rpc/jsonrpc/debug_api.go index 73a36a32f53..295eb1aa716 100644 --- a/rpc/jsonrpc/debug_api.go +++ b/rpc/jsonrpc/debug_api.go @@ -566,7 +566,7 @@ func (api *DebugAPIImpl) AccountAt(ctx context.Context, blockHash common.Hash, t // same plain tx (an overlay-resolved head would have no committed history). blockNumber, err := api._blockReader.HeaderNumber(ctx, tx, blockHash) if err != nil { - return &AccountResult{}, err + return nil, err } if blockNumber == nil { return nil, nil // not error, see https://github.com/erigontech/erigon/issues/1645 From d463e7792edc49084009e6837e68c4edf805ceec Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 12:14:18 +0200 Subject: [PATCH 25/45] rpc: pin overlay view across block reads --- rpc/jsonrpc/debug_api.go | 10 ++++- rpc/jsonrpc/eth_block.go | 9 +++-- rpc/jsonrpc/overlay_race_test.go | 69 +++++++++++++++++++++++++++++++- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/rpc/jsonrpc/debug_api.go b/rpc/jsonrpc/debug_api.go index 295eb1aa716..050aecb8d99 100644 --- a/rpc/jsonrpc/debug_api.go +++ b/rpc/jsonrpc/debug_api.go @@ -627,19 +627,25 @@ type AccountResult struct { // GetRawHeader implements debug_getRawHeader - returns a an RLP-encoded header, given a block number or hash func (api *DebugAPIImpl) GetRawHeader(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (hexutil.Bytes, error) { + if number, ok := blockNrOrHash.Number(); ok && number == rpc.PendingBlockNumber { + if block := api.pendingBlock(); block != nil { + return rlp.EncodeToBytes(block.Header()) + } + } tx, err := api.db.BeginTemporalRo(ctx) if err != nil { return nil, err } defer tx.Rollback() - n, h, _, err := rpchelper.GetBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, api.filters) + overlayTx := api.filters.WithOverlay(tx) + n, h, _, err := rpchelper.GetBlockNumber(ctx, blockNrOrHash, overlayTx, api._blockReader, nil) if err != nil { if errors.As(err, &rpc.BlockNotFoundErr{}) { return nil, nil // waiting for spec: not error, see Geth and https://github.com/erigontech/erigon/issues/1645 } return nil, err } - header, err := api._blockReader.Header(ctx, api.filters.WithOverlay(tx), h, n) + header, err := api._blockReader.Header(ctx, overlayTx, h, n) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_block.go b/rpc/jsonrpc/eth_block.go index 8a7739568ee..743e4e11087 100644 --- a/rpc/jsonrpc/eth_block.go +++ b/rpc/jsonrpc/eth_block.go @@ -343,7 +343,8 @@ func (api *APIImpl) GetBlockTransactionCountByNumber(ctx context.Context, blockN return &n, nil } - blockNum, blockHash, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(blockNr), tx, api._blockReader, api.filters) + overlayTx := api.filters.WithOverlay(tx) + blockNum, blockHash, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(blockNr), overlayTx, api._blockReader, nil) if err != nil { if errors.As(err, &rpc.BlockNotFoundErr{}) { return nil, nil // not error, see https://github.com/erigontech/erigon/issues/1645 @@ -356,7 +357,6 @@ func (api *APIImpl) GetBlockTransactionCountByNumber(ctx context.Context, blockN return nil, err } - overlayTx := api.filters.WithOverlay(tx) latestBlockNumber, err := rpchelper.GetLatestBlockNumber(overlayTx) if err != nil { return nil, err @@ -400,7 +400,8 @@ func (api *APIImpl) GetBlockTransactionCountByHash(ctx context.Context, blockHas } defer tx.Rollback() - blockNum, _, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHash{BlockHash: &blockHash}, tx, api._blockReader, api.filters) + overlayTx := api.filters.WithOverlay(tx) + blockNum, _, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHash{BlockHash: &blockHash}, overlayTx, api._blockReader, nil) if err != nil { // (Compatibility) Every other node just return `null` for when the block does not exist. log.Debug("eth_getBlockTransactionCountByHash GetBlockNumber failed", "err", err) @@ -412,7 +413,7 @@ func (api *APIImpl) GetBlockTransactionCountByHash(ctx context.Context, blockHas return nil, err } - _, txCount, err := api._blockReader.Body(ctx, api.filters.WithOverlay(tx), blockHash, blockNum) + _, txCount, err := api._blockReader.Body(ctx, overlayTx, blockHash, blockNum) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/overlay_race_test.go b/rpc/jsonrpc/overlay_race_test.go index 03cdbc3cd1d..5438c8c32cd 100644 --- a/rpc/jsonrpc/overlay_race_test.go +++ b/rpc/jsonrpc/overlay_race_test.go @@ -29,8 +29,10 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/kvcache" "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/db/services" "github.com/erigontech/erigon/db/state/execctx" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/execmodule/execmoduletester" @@ -59,6 +61,11 @@ const ( // misc.CalcBaseFee leaves BaseFee unchanged, making overlayRaceBaseFee a // reliable, deterministic fingerprint for "the code read the overlay head". func newOverlayAheadTestAPI(t *testing.T) (base *BaseAPI, m *execmoduletester.ExecModuleTester, overlayHeader *types.Header) { + base, m, overlayHeader, _ = newOverlayAheadTestAPIWithEvents(t) + return base, m, overlayHeader +} + +func newOverlayAheadTestAPIWithEvents(t *testing.T) (base *BaseAPI, m *execmoduletester.ExecModuleTester, overlayHeader *types.Header, events *shards.Events) { t.Helper() var cfg chain.Config @@ -102,12 +109,40 @@ func newOverlayAheadTestAPI(t *testing.T) (base *BaseAPI, m *execmoduletester.Ex require.NoError(t, rawdb.WriteCanonicalHash(overlay, hash, overlayNumber)) require.NoError(t, rawdb.WriteBody(overlay, hash, overlayNumber, &types.Body{})) - events := shards.NewEvents() + events = shards.NewEvents() events.PublishOverlay(doms) filters := rpchelper.New(ctx, rpchelper.DefaultFiltersConfig, nil, nil, nil, func() {}, m.Log, events) stateCache := kvcache.New(kvcache.DefaultCoherentConfig) base = newBaseApiWithFiltersForTest(filters, stateCache, m) + return base, m, overlayHeader, events +} + +type unpublishOverlayBlockReader struct { + services.FullBlockReader + events *shards.Events + blockNumber uint64 +} + +func (r *unpublishOverlayBlockReader) CanonicalHash(ctx context.Context, tx kv.Getter, blockNum uint64) (common.Hash, bool, error) { + hash, ok, err := r.FullBlockReader.CanonicalHash(ctx, tx, blockNum) + if err == nil && ok && blockNum == r.blockNumber { + r.events.PublishOverlay(nil) + } + return hash, ok, err +} + +func newOverlayUnpublishTestAPI(t *testing.T) (*BaseAPI, *execmoduletester.ExecModuleTester, *types.Header) { + t.Helper() + base, m, overlayHeader, events := newOverlayAheadTestAPIWithEvents(t) + overlay := events.LatestSD().BlockOverlay() + txn := signOverlayRaceTestTx(t, m, 1) + require.NoError(t, rawdb.WriteBody(overlay, overlayHeader.Hash(), overlayHeader.Number.Uint64(), &types.Body{Transactions: []types.Transaction{txn}})) + base._blockReader = &unpublishOverlayBlockReader{ + FullBlockReader: base._blockReader, + events: events, + blockNumber: overlayHeader.Number.Uint64(), + } return base, m, overlayHeader } @@ -230,6 +265,38 @@ func TestGetBlockTransactionCountByHash_SeesOverlayHead(t *testing.T) { require.Equal(t, *byNumber, *byHash) } +func TestGetBlockTransactionCountByNumber_PinsOverlayView(t *testing.T) { + t.Parallel() + base, m, overlayHeader := newOverlayUnpublishTestAPI(t) + api := newEthApiForTest(base, m.DB, nil, nil) + + count, err := api.GetBlockTransactionCountByNumber(m.Ctx, rpc.BlockNumber(overlayHeader.Number.Uint64())) + require.NoError(t, err) + require.NotNil(t, count) + require.Equal(t, hexutil.Uint(1), *count) +} + +func TestGetBlockTransactionCountByHash_PinsOverlayView(t *testing.T) { + t.Parallel() + base, m, overlayHeader := newOverlayUnpublishTestAPI(t) + api := newEthApiForTest(base, m.DB, nil, nil) + + count, err := api.GetBlockTransactionCountByHash(m.Ctx, overlayHeader.Hash()) + require.NoError(t, err) + require.NotNil(t, count) + require.Equal(t, hexutil.Uint(1), *count) +} + +func TestGetRawHeader_PinsOverlayView(t *testing.T) { + t.Parallel() + base, m, overlayHeader := newOverlayUnpublishTestAPI(t) + api := NewPrivateDebugAPI(base, m.DB, nil, 0, false) + + header, err := api.GetRawHeader(m.Ctx, rpc.BlockNumberOrHashWithNumber(rpc.BlockNumber(overlayHeader.Number.Uint64()))) + require.NoError(t, err) + require.NotNil(t, header) +} + // TestDebugAccountAt_OverlayHeadHash_CommittedView pins that debug_accountAt // resolves the block hash on the committed view: its GetAsOf history reads can // only see committed data, so an overlay-published head must read as an From d3f9e9a6461e06c04753baafbed0703a3612ee25 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 12:28:35 +0200 Subject: [PATCH 26/45] rpc: keep eth_getProof on one read snapshot --- rpc/jsonrpc/eth_call.go | 14 ++++------- rpc/jsonrpc/eth_call_test.go | 46 ++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/rpc/jsonrpc/eth_call.go b/rpc/jsonrpc/eth_call.go index 6d215aa276c..d55efc2a92b 100644 --- a/rpc/jsonrpc/eth_call.go +++ b/rpc/jsonrpc/eth_call.go @@ -467,22 +467,18 @@ func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address co return outputKey } - tx, err := api.db.BeginTemporalRo(ctx) - if err != nil { - return nil, err - } - defer tx.Rollback() // get the root hash from header to validate proofs along the way header, err := api._blockReader.HeaderByNumber(ctx, roTx, blockNrOrHash.BlockNumber.Uint64()) if err != nil { return nil, err } - domains, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) + domains, err := execctx.NewSharedDomains(ctx, roTx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) if err != nil { return nil, err } defer domains.Close() + domains.DetachBranchCache() sdCtx := domains.GetCommitmentContext() // Committed view: the proof computation below reads the same plain roTx. @@ -495,11 +491,11 @@ func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address co } if blockNrOrHash.BlockNumber.Uint64() < latestBlock { // Get first txnum of blockNumber+1 to ensure that correct state root will be restored as of blockNumber has been executed - lastTxnInBlock, err := api._txNumReader.Min(ctx, tx, blockNrOrHash.BlockNumber.Uint64()+1) + lastTxnInBlock, err := api._txNumReader.Min(ctx, roTx, blockNrOrHash.BlockNumber.Uint64()+1) if err != nil { return nil, err } - commitmentStartingTxNum := tx.Debug().HistoryStartFrom(kv.CommitmentDomain) + commitmentStartingTxNum := roTx.Debug().HistoryStartFrom(kv.CommitmentDomain) if lastTxnInBlock < commitmentStartingTxNum { return nil, fmt.Errorf("%w: commitment start: %d, last tx: %d", state.PrunedError, commitmentStartingTxNum, lastTxnInBlock) } @@ -579,7 +575,7 @@ func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address co } } - reader, err := rpchelper.CreateStateReader(ctx, tx, api._blockReader, blockNrOrHash, 0, api.filters, api.stateCache, api._txNumReader) + reader, err := rpchelper.CreateStateReader(ctx, roTx, api._blockReader, blockNrOrHash, 0, nil, api.stateCache, api._txNumReader) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_call_test.go b/rpc/jsonrpc/eth_call_test.go index f6899bc5fa3..e6ef72022fc 100644 --- a/rpc/jsonrpc/eth_call_test.go +++ b/rpc/jsonrpc/eth_call_test.go @@ -429,6 +429,52 @@ func TestGetProof(t *testing.T) { } } +func TestGetProofPinsReadSnapshot(t *testing.T) { + previousSchema := statecfg.Schema + statecfg.EnableHistoricalCommitment() + t.Cleanup(func() { + statecfg.Schema = previousSchema + }) + + m, bankAddress, _, receiverAddress := chainWithDeployedContract(t) + api := newEthApiForTest(newBaseApiForTest(m), m.DB, nil, nil) + + roTx, err := m.DB.BeginTemporalRo(m.Ctx) + require.NoError(t, err) + defer roTx.Rollback() + + parent, err := m.BlockReader.BlockByNumber(m.Ctx, roTx, 6) + require.NoError(t, err) + require.NotNil(t, parent) + + next, err := blockgen.GenerateChain(m.ChainConfig, parent, m.Engine, m.DB, 1, func(_ int, block *blockgen.BlockGen) { + txn, err := types.SignTx(&types.LegacyTx{ + CommonTx: types.CommonTx{ + Nonce: block.TxNonce(bankAddress), + To: &receiverAddress, + GasLimit: 21_000, + Value: *uint256.NewInt(1), + }, + GasPrice: *uint256.NewInt(1_000_000_000_000), + }, *types.LatestSignerForChainID(nil), m.Key) + require.NoError(t, err) + block.AddTx(txn) + }) + require.NoError(t, err) + require.NoError(t, m.InsertChain(next)) + + proof, err := api.getProof( + m.Ctx, + roTx, + bankAddress, + nil, + rpc.BlockNumberOrHashWithNumber(6), + log.New(), + ) + require.NoError(t, err) + require.NotNil(t, proof) +} + func TestGetBlockByTimestampLatestTime(t *testing.T) { ctx := context.Background() m, _, _ := rpcdaemontest.CreateTestExecModule(t) From a5900d8da3da07ce4528a254bcedb24aa423197b Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 13:24:21 +0200 Subject: [PATCH 27/45] db/kv/kvcache, rpc/rpchelper: require GetAsOf on cache views --- db/kv/kvcache/cache.go | 3 +-- db/kv/kvcache/simple.go | 2 -- rpc/rpchelper/helper.go | 13 ++----------- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/db/kv/kvcache/cache.go b/db/kv/kvcache/cache.go index 2397b5b2124..6b303259845 100644 --- a/db/kv/kvcache/cache.go +++ b/db/kv/kvcache/cache.go @@ -55,6 +55,7 @@ type Cache interface { } type CacheView interface { Get(k []byte) ([]byte, error) + GetAsOf(key []byte, ts uint64) (v []byte, ok bool, err error) GetCode(k []byte) ([]byte, error) HasStorage(address common.Address) (bool, error) } @@ -132,8 +133,6 @@ func (c *CoherentView) Get(k []byte) ([]byte, error) { return c.cache.Get(k, c.tx, c.stateVersionID) } -// GetAsOf satisfies the optional capability rpchelper.CreateHistoryCachedStateReader -// asserts; the cache holds latest-state only, so historical reads always fall through. func (c *CoherentView) GetAsOf(key []byte, ts uint64) (v []byte, ok bool, err error) { return nil, false, nil } diff --git a/db/kv/kvcache/simple.go b/db/kv/kvcache/simple.go index bc5433d5750..87202b350fd 100644 --- a/db/kv/kvcache/simple.go +++ b/db/kv/kvcache/simple.go @@ -106,8 +106,6 @@ type SimpleView struct { func (c *SimpleView) Get(k []byte) ([]byte, error) { return c.cache.Get(k, c.tx, 0) } -// GetAsOf satisfies the optional capability rpchelper.CreateHistoryCachedStateReader -// asserts; the cache holds latest-state only, so historical reads always fall through. func (c *SimpleView) GetAsOf(key []byte, ts uint64) (v []byte, ok bool, err error) { return nil, false, nil } diff --git a/rpc/rpchelper/helper.go b/rpc/rpchelper/helper.go index e26903e7bc4..a44925b82e4 100644 --- a/rpc/rpchelper/helper.go +++ b/rpc/rpchelper/helper.go @@ -23,7 +23,6 @@ import ( "github.com/holiman/uint256" "github.com/erigontech/erigon/common" - "github.com/erigontech/erigon/common/dbg" "github.com/erigontech/erigon/db/dbservices" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/kvcache" @@ -229,15 +228,7 @@ func CreateLatestCachedStateReader(cache kvcache.CacheView, tx kv.TemporalTx) st return state.NewCachedReader3(cache, tx) } -type asOfView interface { - GetAsOf(key []byte, ts uint64) (v []byte, ok bool, err error) -} - func CreateHistoryCachedStateReader(ctx context.Context, cache kvcache.CacheView, tx kv.TemporalTx, blockNumber uint64, txnIndex int, txNumsReader rawdbv3.TxNumsReader) (state.StateReader, error) { - asOfView, ok := cache.(asOfView) - if !ok { - return nil, fmt.Errorf("%T does not implement GetAsOf at: %s", cache, dbg.Stack()) - } minTxNum, err := txNumsReader.Min(ctx, tx, blockNumber) if err != nil { return nil, err @@ -247,14 +238,14 @@ func CreateHistoryCachedStateReader(ctx context.Context, cache kvcache.CacheView return nil, fmt.Errorf("%w: block tx: %d, min tx: %d", state.PrunedError, txNum, minHistoryTxNum) } return &cachedHistoryReaderV3{ - cache: asOfView, + cache: cache, reader: state.NewHistoryReaderV3(tx, txNum), composite: make([]byte, 0, len(common.Address{})+len(common.Hash{})), }, nil } type cachedHistoryReaderV3 struct { - cache asOfView + cache kvcache.CacheView reader *state.HistoryReaderV3 composite []byte } From 27eb22fd0d79e72c091f919b73a6311049a0d204 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 13:31:18 +0200 Subject: [PATCH 28/45] db/kv/kvcache: remove obsolete canonical root state --- db/kv/kvcache/cache.go | 11 ----------- db/kv/kvcache/cache_test.go | 10 ---------- 2 files changed, 21 deletions(-) diff --git a/db/kv/kvcache/cache.go b/db/kv/kvcache/cache.go index 6b303259845..f8b06fa1d02 100644 --- a/db/kv/kvcache/cache.go +++ b/db/kv/kvcache/cache.go @@ -84,14 +84,6 @@ type CacheView interface { // High-level guaranties: // - Keys/Values returned by cache are valid/immutable until end of db transaction // - CacheView is always coherent with given db transaction - -// -// Rules of set view.isCanonical value: -// - only OnNewBlock method can set view.isCanonical=true (from StateChanges) -// - roots created by View (readers ahead of their batch) stay non-canonical -// -// Rules of filling cache.stateEvict: -// - changes in Canonical View SHOULD reflect in stateEvict -// - changes in Non-Canonical View SHOULD NOT reflect in stateEvict type Coherent struct { codeEvictLen metrics.Gauge codeKeys metrics.Gauge @@ -118,7 +110,6 @@ type CoherentRoot struct { ready chan struct{} // close when ready readyChanClosed atomic.Bool // quick check if ready channel is closed closeOnce sync.Once // protecting `ready` field from double-close - isCanonical bool } // CoherentView - dumb object, which proxy all requests to Coherent object. @@ -258,8 +249,6 @@ func (c *Coherent) advanceRoot(stateVersionID uint64) (r *CoherentRoot) { return true }) } - r.isCanonical = true - c.evictRoots() c.latestStateVersionID = stateVersionID c.latestStateView = r diff --git a/db/kv/kvcache/cache_test.go b/db/kv/kvcache/cache_test.go index a6cd793207c..819a923b43d 100644 --- a/db/kv/kvcache/cache_test.go +++ b/db/kv/kvcache/cache_test.go @@ -49,7 +49,6 @@ func TestEvictionInUnexpectedOrder(t *testing.T) { c.selectOrCreateRoot(2) require.Len(c.roots, 1) require.Zero(int(c.latestStateVersionID)) - require.False(c.roots[2].isCanonical) c.add([]byte{1}, nil, c.roots[2], 2) require.Zero(c.stateEvict.Len()) @@ -57,7 +56,6 @@ func TestEvictionInUnexpectedOrder(t *testing.T) { c.advanceRoot(2) require.Len(c.roots, 1) require.Equal(2, int(c.latestStateVersionID)) - require.True(c.roots[2].isCanonical) c.add([]byte{1}, nil, c.roots[2], 2) require.Equal(1, c.stateEvict.Len()) @@ -65,7 +63,6 @@ func TestEvictionInUnexpectedOrder(t *testing.T) { c.selectOrCreateRoot(5) require.Len(c.roots, 2) require.Equal(2, int(c.latestStateVersionID)) - require.False(c.roots[5].isCanonical) c.add([]byte{2}, nil, c.roots[5], 5) // not added to evict list require.Equal(1, c.stateEvict.Len()) @@ -75,32 +72,26 @@ func TestEvictionInUnexpectedOrder(t *testing.T) { c.selectOrCreateRoot(6) require.Len(c.roots, 3) require.Equal(2, int(c.latestStateVersionID)) - require.False(c.roots[6].isCanonical) // parrent exists, but parent has isCanonical=false c.advanceRoot(3) require.Len(c.roots, 4) require.Equal(3, int(c.latestStateVersionID)) - require.True(c.roots[3].isCanonical) c.advanceRoot(4) require.Len(c.roots, 5) require.Equal(4, int(c.latestStateVersionID)) - require.True(c.roots[4].isCanonical) c.selectOrCreateRoot(5) require.Len(c.roots, 5) require.Equal(4, int(c.latestStateVersionID)) - require.False(c.roots[5].isCanonical) c.advanceRoot(5) require.Len(c.roots, 5) require.Equal(5, int(c.latestStateVersionID)) - require.True(c.roots[5].isCanonical) c.advanceRoot(100) require.Len(c.roots, 6) require.Equal(100, int(c.latestStateVersionID)) - require.True(c.roots[100].isCanonical) //c.add([]byte{1}, nil, c.roots[2], 2) require.Equal(0, c.latestStateView.cache.Len()) @@ -192,7 +183,6 @@ func TestCanonicalRootsStartFresh(t *testing.T) { require.Equal(1, c.roots[2].cache.Len()) c.OnNewBlock(&remoteproto.StateChangeBatch{StateVersionId: 3}) - require.True(c.roots[3].isCanonical) require.Zero(c.roots[3].cache.Len()) } From 4f628ec58542af3fc87a4f709fe5e296831d4465 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 13:39:00 +0200 Subject: [PATCH 29/45] db/kv/kvcache: centralize coherent root initialization --- db/kv/kvcache/cache.go | 45 +++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/db/kv/kvcache/cache.go b/db/kv/kvcache/cache.go index f8b06fa1d02..4b2ab5f9924 100644 --- a/db/kv/kvcache/cache.go +++ b/db/kv/kvcache/cache.go @@ -193,6 +193,14 @@ func New(cfg CoherentConfig) *Coherent { } } +func newCoherentRoot() *CoherentRoot { + return &CoherentRoot{ + ready: make(chan struct{}), + cache: btree2.NewBTreeG(Less), + codeCache: btree2.NewBTreeG(Less), + } +} + // selectOrCreateRoot - used for usual getting root func (c *Coherent) selectOrCreateRoot(versionID uint64) *CoherentRoot { c.lock.Lock() @@ -202,11 +210,7 @@ func (c *Coherent) selectOrCreateRoot(versionID uint64) *CoherentRoot { return r } - r = &CoherentRoot{ - ready: make(chan struct{}), - cache: btree2.NewBTreeG(Less), - codeCache: btree2.NewBTreeG(Less), - } + r = newCoherentRoot() c.roots[versionID] = r return r } @@ -221,7 +225,7 @@ func (c *Coherent) advanceRoot(stateVersionID uint64) (r *CoherentRoot) { } if !rootExists { - r = &CoherentRoot{ready: make(chan struct{})} + r = newCoherentRoot() c.roots[stateVersionID] = r } @@ -232,23 +236,18 @@ func (c *Coherent) advanceRoot(stateVersionID uint64) (r *CoherentRoot) { // producer gap to one version. c.stateEvict.Init() c.codeEvict.Init() - if r.cache == nil { - r.cache = btree2.NewBTreeG(Less) - r.codeCache = btree2.NewBTreeG(Less) - } else { - r.cache.Walk(func(items []*Element) bool { - for _, i := range items { - c.stateEvict.PushFront(i) - } - return true - }) - r.codeCache.Walk(func(items []*Element) bool { - for _, i := range items { - c.codeEvict.PushFront(i) - } - return true - }) - } + r.cache.Walk(func(items []*Element) bool { + for _, i := range items { + c.stateEvict.PushFront(i) + } + return true + }) + r.codeCache.Walk(func(items []*Element) bool { + for _, i := range items { + c.codeEvict.PushFront(i) + } + return true + }) c.evictRoots() c.latestStateVersionID = stateVersionID c.latestStateView = r From a7082f4a099213db125cc9e0c99086a7d2095c92 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 13:44:42 +0200 Subject: [PATCH 30/45] rpc/jsonrpc: reuse resolved block in eth_getProof --- rpc/jsonrpc/eth_call.go | 26 +++++++++----------------- rpc/jsonrpc/eth_call_test.go | 3 ++- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/rpc/jsonrpc/eth_call.go b/rpc/jsonrpc/eth_call.go index d55efc2a92b..67d52e7d8ea 100644 --- a/rpc/jsonrpc/eth_call.go +++ b/rpc/jsonrpc/eth_call.go @@ -430,14 +430,14 @@ func (api *APIImpl) GetProof(ctx context.Context, address common.Address, storag // nil filters: resolve on the committed view — getProof gates on and reads // the same plain roTx (see rpchelper.GetBlockNumber). - requestedBlockNr, _, _, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, roTx, api._blockReader, nil) + blockNumber, _, isLatest, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, roTx, api._blockReader, nil) if err != nil { return nil, err - } else if requestedBlockNr == 0 { + } else if blockNumber == 0 { return nil, errors.New("block not found") } - err = api.BaseAPI.checkPruneHistory(ctx, roTx, uint64(requestedBlockNr)) + err = api.BaseAPI.checkPruneHistory(ctx, roTx, blockNumber) if err != nil { return nil, err } @@ -447,10 +447,10 @@ func (api *APIImpl) GetProof(ctx context.Context, address common.Address, storag storageKeysConverted[i].Hash.SetBytes(s) storageKeysConverted[i].KeyLength = len(s) } - return api.getProof(ctx, roTx, address, storageKeysConverted, rpc.BlockNumberOrHashWithNumber(rpc.BlockNumber(requestedBlockNr)), api.logger) + return api.getProof(ctx, roTx, address, storageKeysConverted, blockNumber, isLatest, api.logger) } -func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address common.Address, storageKeys []StorageKeysInfo, blockNrOrHash rpc.BlockNumberOrHash, logger log.Logger) (*accounts.AccProofResult, error) { +func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address common.Address, storageKeys []StorageKeysInfo, blockNumber uint64, isLatest bool, logger log.Logger) (*accounts.AccProofResult, error) { // Output key encoding is a bit special: if the input was a 32-byte hash, it is // returned as such. Otherwise, we apply the QUANTITY encoding mandated by the @@ -468,7 +468,7 @@ func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address co } // get the root hash from header to validate proofs along the way - header, err := api._blockReader.HeaderByNumber(ctx, roTx, blockNrOrHash.BlockNumber.Uint64()) + header, err := api._blockReader.HeaderByNumber(ctx, roTx, blockNumber) if err != nil { return nil, err } @@ -481,17 +481,9 @@ func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address co domains.DetachBranchCache() sdCtx := domains.GetCommitmentContext() - // Committed view: the proof computation below reads the same plain roTx. - latestBlock, err := rpchelper.GetLatestBlockNumber(roTx) - if err != nil { - return nil, err - } - if latestBlock < blockNrOrHash.BlockNumber.Uint64() { - return nil, fmt.Errorf("block number is in the future latest=%d requested=%d", latestBlock, blockNrOrHash.BlockNumber.Uint64()) - } - if blockNrOrHash.BlockNumber.Uint64() < latestBlock { + if !isLatest { // Get first txnum of blockNumber+1 to ensure that correct state root will be restored as of blockNumber has been executed - lastTxnInBlock, err := api._txNumReader.Min(ctx, roTx, blockNrOrHash.BlockNumber.Uint64()+1) + lastTxnInBlock, err := api._txNumReader.Min(ctx, roTx, blockNumber+1) if err != nil { return nil, err } @@ -575,7 +567,7 @@ func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address co } } - reader, err := rpchelper.CreateStateReader(ctx, roTx, api._blockReader, blockNrOrHash, 0, nil, api.stateCache, api._txNumReader) + reader, err := rpchelper.CreateStateReaderFromBlockNumber(ctx, roTx, blockNumber, isLatest, 0, api.stateCache, api._txNumReader) if err != nil { return nil, err } diff --git a/rpc/jsonrpc/eth_call_test.go b/rpc/jsonrpc/eth_call_test.go index 27f0729357a..6a560af9814 100644 --- a/rpc/jsonrpc/eth_call_test.go +++ b/rpc/jsonrpc/eth_call_test.go @@ -468,7 +468,8 @@ func TestGetProofPinsReadSnapshot(t *testing.T) { roTx, bankAddress, nil, - rpc.BlockNumberOrHashWithNumber(6), + 6, + true, log.New(), ) require.NoError(t, err) From 8f04ba0a38533dbd381d71eb67e89b3a4833b8fa Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 14:02:33 +0200 Subject: [PATCH 31/45] rpc, cmd, docs: clarify block lookup and commit wording --- cmd/utils/flags.go | 2 +- docs/site/docs/fundamentals/configuring-erigon.mdx | 2 +- docs/site/static/llms-full.txt | 2 +- llms-full.txt | 2 +- rpc/jsonrpc/trace_filtering.go | 2 +- rpc/rpchelper/helper.go | 3 +-- 6 files changed, 6 insertions(+), 7 deletions(-) diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go index 722e8c91ad2..2a38acb36a1 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -1143,7 +1143,7 @@ var ( } FcuBackgroundCommitFlag = cli.BoolFlag{ Name: "fcu.background.commit", - Usage: "Return FCU response before MDBX flush+commit lands (commit runs in background; remote rpcdaemon 'latest' lags ~50ms, stays consistent)", + Usage: "Return FCU response before MDBX flush+commit lands (commit runs in background; remote rpcdaemon 'latest' stays consistent but can lag for the commit duration)", Value: ethconfig.Defaults.FcuBackgroundCommit, } MCPDisableFlag = cli.BoolFlag{ diff --git a/docs/site/docs/fundamentals/configuring-erigon.mdx b/docs/site/docs/fundamentals/configuring-erigon.mdx index bedbffb1cde..5544f7b456b 100644 --- a/docs/site/docs/fundamentals/configuring-erigon.mdx +++ b/docs/site/docs/fundamentals/configuring-erigon.mdx @@ -417,7 +417,7 @@ Flags for configuring Fork Choice Update behavior. * Default: `1s` * `--fcu.background.prune`: Enables background pruning after FCU. * Default: `true` -* `--fcu.background.commit`: Returns the FCU response before MDBX flush+commit lands (commit runs in background; remote `rpcdaemon` `latest` lags ~50ms, stays consistent). +* `--fcu.background.commit`: Returns the FCU response before MDBX flush+commit lands (commit runs in background; remote `rpcdaemon` `latest` stays consistent but can lag for the commit duration). * Default: `false` ### Execution diff --git a/docs/site/static/llms-full.txt b/docs/site/static/llms-full.txt index e53e10a6f56..80d29142d5e 100644 --- a/docs/site/static/llms-full.txt +++ b/docs/site/static/llms-full.txt @@ -2299,7 +2299,7 @@ Flags for configuring Fork Choice Update behavior. * Default: `1s` * `--fcu.background.prune`: Enables background pruning after FCU. * Default: `true` -* `--fcu.background.commit`: Returns the FCU response before MDBX flush+commit lands (commit runs in background; remote `rpcdaemon` `latest` lags ~50ms, stays consistent). +* `--fcu.background.commit`: Returns the FCU response before MDBX flush+commit lands (commit runs in background; remote `rpcdaemon` `latest` stays consistent but can lag for the commit duration). * Default: `false` ### Execution diff --git a/llms-full.txt b/llms-full.txt index e53e10a6f56..80d29142d5e 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -2299,7 +2299,7 @@ Flags for configuring Fork Choice Update behavior. * Default: `1s` * `--fcu.background.prune`: Enables background pruning after FCU. * Default: `true` -* `--fcu.background.commit`: Returns the FCU response before MDBX flush+commit lands (commit runs in background; remote `rpcdaemon` `latest` lags ~50ms, stays consistent). +* `--fcu.background.commit`: Returns the FCU response before MDBX flush+commit lands (commit runs in background; remote `rpcdaemon` `latest` stays consistent but can lag for the commit duration). * Default: `false` ### Execution diff --git a/rpc/jsonrpc/trace_filtering.go b/rpc/jsonrpc/trace_filtering.go index 2e6fb617e79..4dc8a424534 100644 --- a/rpc/jsonrpc/trace_filtering.go +++ b/rpc/jsonrpc/trace_filtering.go @@ -345,7 +345,7 @@ func (api *TraceAPIImpl) Filter(ctx context.Context, req TraceFilterRequest, gas return err } if headNumber == nil { - return errors.New("current header number not found") + return errors.New("head header not found") } toBlock = *headNumber } else { diff --git a/rpc/rpchelper/helper.go b/rpc/rpchelper/helper.go index a44925b82e4..6b0277caaff 100644 --- a/rpc/rpchelper/helper.go +++ b/rpc/rpchelper/helper.go @@ -56,8 +56,7 @@ func CheckBlockExecuted(tx kv.Tx, blockNumber uint64) error { return nil } -// GetBlockNumber resolves a block number, hash, or tag ("latest", "safe", -// "finalized", "pending") to a concrete block number and hash. +// GetBlockNumber resolves a block number, hash, or tag to a concrete block number and hash. // // filters controls which view tags resolve against. Pass the API's Filters to // resolve through the block overlay, which includes a head whose commit is From 3c142a71fdf0f6b9b05d4a60902f441e5fa80943 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 15:05:42 +0200 Subject: [PATCH 32/45] db/kv/kvcache: drop dead evict-list re-feed in advanceRoot Fills skip non-latest versions and a same-version re-announce returns early, so any root reaching the re-feed walk is empty. Item 4 of #22499. --- db/kv/kvcache/cache.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/db/kv/kvcache/cache.go b/db/kv/kvcache/cache.go index 4b2ab5f9924..30cd1720bbb 100644 --- a/db/kv/kvcache/cache.go +++ b/db/kv/kvcache/cache.go @@ -236,18 +236,6 @@ func (c *Coherent) advanceRoot(stateVersionID uint64) (r *CoherentRoot) { // producer gap to one version. c.stateEvict.Init() c.codeEvict.Init() - r.cache.Walk(func(items []*Element) bool { - for _, i := range items { - c.stateEvict.PushFront(i) - } - return true - }) - r.codeCache.Walk(func(items []*Element) bool { - for _, i := range items { - c.codeEvict.PushFront(i) - } - return true - }) c.evictRoots() c.latestStateVersionID = stateVersionID c.latestStateView = r From 03df20fde5c58a8bf97b7547047925e4eb1963a2 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 15:18:34 +0200 Subject: [PATCH 33/45] engineapi: make retryBusy backoff unconditional A log-ticker fire won the select and retried immediately, skipping the 50ms backoff; sleep first (ctx-aware) and drain the ticker non-blocking. Also avoids a fresh time.After timer per iteration. --- .../engineapi/engine_block_downloader/block_downloader.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/execution/engineapi/engine_block_downloader/block_downloader.go b/execution/engineapi/engine_block_downloader/block_downloader.go index 21ee2ca63dd..8cfbaa00d46 100644 --- a/execution/engineapi/engine_block_downloader/block_downloader.go +++ b/execution/engineapi/engine_block_downloader/block_downloader.go @@ -279,12 +279,13 @@ func (e *EngineBlockDownloader) retryBusy(ctx context.Context, label string, cal logEvery := time.NewTicker(5 * time.Second) defer logEvery.Stop() for err == nil && status == execmodule.ExecutionStatusBusy { + if err := common.Sleep(ctx, 50*time.Millisecond); err != nil { + return status, validationErr, lastValidHash, err + } select { - case <-ctx.Done(): - return status, validationErr, lastValidHash, ctx.Err() case <-logEvery.C: e.logger.Debug("[EngineBlockDownloader] execution busy - retrying", "label", label) - case <-time.After(50 * time.Millisecond): + default: } status, validationErr, lastValidHash, err = call() } From 9a761c21081c814b212365493f77a1f6ec0dbd6d Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 15:28:37 +0200 Subject: [PATCH 34/45] db/kv/membatchwithdb: enforce memStore backing via memTx's type NewMemoryBatch is the only constructor left, so the read-view safety invariant newReadViewMut asserted at runtime is now compile-time: type the field *memStore and drop the panic. --- db/kv/membatchwithdb/memory_mutation.go | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/db/kv/membatchwithdb/memory_mutation.go b/db/kv/membatchwithdb/memory_mutation.go index f077e1999cb..d82322982d7 100644 --- a/db/kv/membatchwithdb/memory_mutation.go +++ b/db/kv/membatchwithdb/memory_mutation.go @@ -44,7 +44,7 @@ type MemoryMutation struct { // Read views created via NewReadView share this pointer so they synchronize // with the parent's writers. mu *sync.RWMutex - memTx kv.RwTx + memTx *memStore // concrete type is load-bearing — see newReadViewMut memDb kv.RwDB deletedEntries map[string]map[string]struct{} deletedDups map[string]map[string]map[string]struct{} @@ -545,7 +545,7 @@ func (m *MemoryMutation) Commit() error { } // Safe to close while read views are still iterating: the memStore backing -// makes Rollback a no-op on the data (asserted in newReadViewMut). +// makes Rollback a no-op on the data (see newReadViewMut). func (m *MemoryMutation) Rollback() { m.memTx.Rollback() m.memDb.Close() @@ -1020,14 +1020,12 @@ func (m *MemoryMutation) NewReadView(tx kv.Tx) kv.TemporalTx { // newReadViewMut is the internal constructor that returns the full // *MemoryMutation. Used by NewTemporalReadView which needs to embed it. +// +// Read views stay safe under a concurrent parent Close only because the +// pure-Go memStore's Rollback/Close are no-ops on its data — memTx's type +// enforces that backing. A real-DB-backed memTx would invalidate cursors +// mid-iteration and need refcount/drain logic at the parent's Close. func (m *MemoryMutation) newReadViewMut(tx kv.Tx) *MemoryMutation { - // Read views stay safe under a concurrent parent Close only because the - // pure-Go memStore's Rollback/Close are no-ops on its data; a real-DB-backed - // memTx would invalidate cursors mid-iteration. Relaxing this assertion - // requires refcount/drain logic at the parent's Close. - if _, ok := m.memTx.(*memStore); !ok { - panic(fmt.Sprintf("MemoryMutation.newReadViewMut: shared-tx read views require pure-Go memStore backing; got %T (use NewMemoryBatch)", m.memTx)) - } var dbTx kv.TemporalTx if t, ok := tx.(kv.TemporalTx); ok { dbTx = t From 7b2eff4030b4ef37bbeb527db280acd935ac16dd Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 16:22:02 +0200 Subject: [PATCH 35/45] rpc: pin eth_getProof reads to DB snapshot --- rpc/jsonrpc/eth_call.go | 11 +++++++--- rpc/jsonrpc/eth_call_test.go | 40 +++++++++++++++++------------------- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/rpc/jsonrpc/eth_call.go b/rpc/jsonrpc/eth_call.go index 67d52e7d8ea..a723bc82d05 100644 --- a/rpc/jsonrpc/eth_call.go +++ b/rpc/jsonrpc/eth_call.go @@ -567,9 +567,14 @@ func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address co } } - reader, err := rpchelper.CreateStateReaderFromBlockNumber(ctx, roTx, blockNumber, isLatest, 0, api.stateCache, api._txNumReader) - if err != nil { - return nil, err + var reader state.StateReader + if isLatest { + reader = rpchelper.NewLatestStateReader(roTx) + } else { + reader, err = rpchelper.CreateHistoryStateReader(ctx, roTx, blockNumber+1, 0, api._txNumReader) + if err != nil { + return nil, err + } } // get storage key proofs diff --git a/rpc/jsonrpc/eth_call_test.go b/rpc/jsonrpc/eth_call_test.go index 6a560af9814..c5f66d4b839 100644 --- a/rpc/jsonrpc/eth_call_test.go +++ b/rpc/jsonrpc/eth_call_test.go @@ -43,9 +43,11 @@ import ( "github.com/erigontech/erigon/db/kv/kvcache" "github.com/erigontech/erigon/db/kv/rawdbv3" "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/db/state/execctx" "github.com/erigontech/erigon/db/state/statecfg" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/commitment/trie" + "github.com/erigontech/erigon/execution/execmodule" "github.com/erigontech/erigon/execution/execmodule/execmoduletester" "github.com/erigontech/erigon/execution/protocol" "github.com/erigontech/erigon/execution/protocol/params" @@ -436,44 +438,40 @@ func TestGetProofPinsReadSnapshot(t *testing.T) { statecfg.Schema = previousSchema }) - m, bankAddress, _, receiverAddress := chainWithDeployedContract(t) - api := newEthApiForTest(newBaseApiForTest(m), m.DB, nil, nil) + m, _, contractAddress, _ := chainWithDeployedContract(t) roTx, err := m.DB.BeginTemporalRo(m.Ctx) require.NoError(t, err) defer roTx.Rollback() - parent, err := m.BlockReader.BlockByNumber(m.Ctx, roTx, 6) + publishedDomains, err := execctx.NewSharedDomains(m.Ctx, roTx, m.Log) require.NoError(t, err) - require.NotNil(t, parent) + defer publishedDomains.Close() - next, err := blockgen.GenerateChain(m.ChainConfig, parent, m.Engine, m.DB, 1, func(_ int, block *blockgen.BlockGen) { - txn, err := types.SignTx(&types.LegacyTx{ - CommonTx: types.CommonTx{ - Nonce: block.TxNonce(bankAddress), - To: &receiverAddress, - GasLimit: 21_000, - Value: *uint256.NewInt(1), - }, - GasPrice: *uint256.NewInt(1_000_000_000_000), - }, *types.LatestSignerForChainID(nil), m.Key) - require.NoError(t, err) - block.AddTx(txn) - }) - require.NoError(t, err) - require.NoError(t, m.InsertChain(next)) + storageKey := common.Hash{} + compositeKey := make([]byte, 0, len(contractAddress)+len(storageKey)) + compositeKey = append(compositeKey, contractAddress[:]...) + compositeKey = append(compositeKey, storageKey[:]...) + require.NoError(t, publishedDomains.DomainPut(kv.StorageDomain, roTx, compositeKey, []byte{3}, 1, nil)) + + stateCache := &execmodule.Cache{} + stateCache.SetPublishedSD(func() *execctx.SharedDomains { return publishedDomains }) + base := newBaseApiForTest(m) + base.stateCache = stateCache + api := newEthApiForTest(base, m.DB, nil, nil) proof, err := api.getProof( m.Ctx, roTx, - bankAddress, - nil, + contractAddress, + []StorageKeysInfo{{Hash: storageKey, KeyLength: len(storageKey)}}, 6, true, log.New(), ) require.NoError(t, err) require.NotNil(t, proof) + require.Equal(t, uint64(2), (*big.Int)(proof.StorageProof[0].Value).Uint64()) } func TestGetBlockByTimestampLatestTime(t *testing.T) { From 20cac1afcc94f8015d40286954dcd31494484428 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 16:37:48 +0200 Subject: [PATCH 36/45] db/kv/kvcache: bound retained root memory --- db/kv/kvcache/cache.go | 4 ++++ db/kv/kvcache/cache_test.go | 42 +++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/db/kv/kvcache/cache.go b/db/kv/kvcache/cache.go index 30cd1720bbb..c4540e90e5d 100644 --- a/db/kv/kvcache/cache.go +++ b/db/kv/kvcache/cache.go @@ -234,6 +234,10 @@ func (c *Coherent) advanceRoot(stateVersionID uint64) (r *CoherentRoot) { // unwind — https://github.com/erigontech/erigon/issues/22276), so // inherited entries could stay stale forever. Fresh roots bound any // producer gap to one version. + for _, root := range c.roots { + root.cache.Clear() + root.codeCache.Clear() + } c.stateEvict.Init() c.codeEvict.Init() c.evictRoots() diff --git a/db/kv/kvcache/cache_test.go b/db/kv/kvcache/cache_test.go index 819a923b43d..57f1e05186d 100644 --- a/db/kv/kvcache/cache_test.go +++ b/db/kv/kvcache/cache_test.go @@ -186,6 +186,48 @@ func TestCanonicalRootsStartFresh(t *testing.T) { require.Zero(c.roots[3].cache.Len()) } +func TestRetainedRootsShareCacheBudgets(t *testing.T) { + require := require.New(t) + cfg := DefaultCoherentConfig + cfg.CacheSize = 21 + cfg.CodeCacheSize = 21 + cfg.NewBlockWait = 0 + c := New(cfg) + + addVersion := func(version uint64, addr [20]byte) { + c.OnNewBlock(&remoteproto.StateChangeBatch{ + StateVersionId: version, + ChangeBatch: []*remoteproto.StateChange{{ + Direction: remoteproto.Direction_FORWARD, + Changes: []*remoteproto.AccountChange{{ + Action: remoteproto.Action_UPSERT_CODE, + Address: gointerfaces.ConvertAddressToH160(addr), + Data: []byte{byte(version)}, + Code: []byte{byte(version)}, + }}, + }}, + }) + } + + addVersion(1, [20]byte{1}) + addVersion(2, [20]byte{2}) + require.Len(c.roots, 2) + + var stateSize, codeSize int + for _, root := range c.roots { + root.cache.Scan(func(element *Element) bool { + stateSize += element.Size() + return true + }) + root.codeCache.Scan(func(element *Element) bool { + codeSize += element.Size() + return true + }) + } + require.LessOrEqual(stateSize, int(cfg.CacheSize.Bytes())) + require.LessOrEqual(codeSize, int(cfg.CodeCacheSize.Bytes())) +} + // Batch-fed storage entries must be stored under the key shape readers use: // address+location (see state.CachedReader3.ReadAccountStorage). func TestOnNewBlockStorageKeysMatchReaders(t *testing.T) { From fb1c204045e2483d2ab4a41ccab37b6be026bd4d Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 16:48:08 +0200 Subject: [PATCH 37/45] cmd/rpcdaemon: keep zero-budget state reads coherent --- cmd/rpcdaemon/cli/config.go | 23 ++++++------ cmd/rpcdaemon/cli/config_test.go | 61 ++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/cmd/rpcdaemon/cli/config.go b/cmd/rpcdaemon/cli/config.go index 76aaeceebb3..30310d2af9a 100644 --- a/cmd/rpcdaemon/cli/config.go +++ b/cmd/rpcdaemon/cli/config.go @@ -138,7 +138,7 @@ func RootCommand() (*cobra.Command, *httpcfg.HttpCfg) { rootCmd.PersistentFlags().BoolVar(&cfg.GethCompatibility, "rpc.gethcompat", false, "Enables Geth-compatible storage iteration order for debug_storageRangeAt (sorted by keccak256 hash). Disabled by default for performance.") rootCmd.PersistentFlags().StringVar(&cfg.TxPoolApiAddr, "txpool.api.addr", "", "txpool api network address, for example: 127.0.0.1:9090 (default: use value of --private.api.addr)") - rootCmd.PersistentFlags().StringVar(&stateCacheStr, "state.cache", "0MB", "Amount of data to store in the version-keyed StateCache (an equally-sized code cache is budgeted on top). Set 0 to fall back to a non-versioned last-block cache") + rootCmd.PersistentFlags().StringVar(&stateCacheStr, "state.cache", "0MB", "Amount of data to store in the version-keyed StateCache (an equally-sized code cache is budgeted on top). Set 0 to disable entry retention while preserving snapshot-consistent reads") rootCmd.PersistentFlags().BoolVar(&cfg.GRPCServerEnabled, "grpc", false, "Enable GRPC server") rootCmd.PersistentFlags().StringVar(&cfg.GRPCListenAddress, "grpc.addr", nodecfg.DefaultGRPCHost, "GRPC server listening interface") rootCmd.PersistentFlags().IntVar(&cfg.GRPCPort, "grpc.port", nodecfg.DefaultGRPCPort, "GRPC server listening port") @@ -242,6 +242,13 @@ type StateChangesClient interface { StateChanges(ctx context.Context, in *remoteproto.StateChangeRequest, opts ...grpc.CallOption) (remoteproto.KV_StateChangesClient, error) } +func newRemoteStateCache(cfg kvcache.CoherentConfig) kvcache.Cache { + if cfg.CacheSize == 0 && cfg.CodeCacheSize == 0 { + cfg.WaitForNewBlock = false + } + return kvcache.New(cfg) +} + func subscribeToStateChangesLoop(ctx context.Context, client StateChangesClient, cache kvcache.Cache) { go func() { for { @@ -334,11 +341,8 @@ func EmbeddedServices(ctx context.Context, // the overlay is always current, has zero memory overhead, and // doesn't need the StateChanges gRPC stream to stay coherent. stateCache = stateCacheCfg.LocalCache - } else if stateCacheCfg.CacheSize > 0 { - // Remote RPCDaemon: use coherent cache fed by StateChanges stream. - stateCache = kvcache.New(stateCacheCfg) } else { - stateCache = kvcache.NewSimple() + stateCache = newRemoteStateCache(stateCacheCfg) } subscribeToStateChangesLoop(ctx, stateDiffClient, stateCache) @@ -540,14 +544,7 @@ func RemoteServices(ctx context.Context, cfg *httpcfg.HttpCfg, logger log.Logger logger.Info("if you run RPCDaemon on same machine with Erigon add --datadir option") } - // State-change batches arrive before the EL commits them; the Coherent - // cache keys entries by PlainStateVersion so reads stay consistent with - // this daemon's committed view, while SimpleCache serves them immediately. - if cfg.StateCache.CacheSize > 0 { - stateCache = kvcache.New(cfg.StateCache) - } else { - stateCache = kvcache.NewSimple() - } + stateCache = newRemoteStateCache(cfg.StateCache) subscribeToStateChangesLoop(ctx, remoteKvClient, stateCache) diff --git a/cmd/rpcdaemon/cli/config_test.go b/cmd/rpcdaemon/cli/config_test.go index 2c6105c669e..f5732042ce2 100644 --- a/cmd/rpcdaemon/cli/config_test.go +++ b/cmd/rpcdaemon/cli/config_test.go @@ -24,11 +24,20 @@ import ( "github.com/holiman/uint256" "github.com/stretchr/testify/require" + "github.com/erigontech/erigon/common" "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/kvcache" + "github.com/erigontech/erigon/db/kv/temporal/temporaltest" + "github.com/erigontech/erigon/db/state/execctx" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/protocol/rules/ethash" "github.com/erigontech/erigon/execution/protocol/rules/merge" "github.com/erigontech/erigon/execution/types" + "github.com/erigontech/erigon/execution/types/accounts" + "github.com/erigontech/erigon/node/gointerfaces" + "github.com/erigontech/erigon/node/gointerfaces/remoteproto" ) // TestIsWebsocket tests if an incoming websocket upgrade request is detected properly. @@ -70,3 +79,55 @@ func TestRemoteRulesEngineFinalizeDelegates(t *testing.T) { require.NoError(t, err) }) } + +func TestZeroBudgetRemoteCachePinsCommittedState(t *testing.T) { + cfg := kvcache.DefaultCoherentConfig + cfg.CacheSize = 0 + cfg.CodeCacheSize = 0 + cache := newRemoteStateCache(cfg) + + db := temporaltest.NewTestDB(t, datadir.New(t.TempDir())) + addr := common.Address{1} + committedAccount := accounts.Account{Nonce: 1, Balance: *uint256.NewInt(1), CodeHash: accounts.EmptyCodeHash} + announcedAccount := committedAccount + announcedAccount.Nonce = 2 + committedData := accounts.SerialiseV3(&committedAccount) + announcedData := accounts.SerialiseV3(&announcedAccount) + + require.NoError(t, db.UpdateTemporal(t.Context(), func(tx kv.TemporalRwTx) error { + domains, err := execctx.NewSharedDomains(t.Context(), tx, log.New()) + if err != nil { + return err + } + defer domains.Close() + if err := domains.DomainPut(kv.AccountsDomain, tx, addr[:], committedData, 0, nil); err != nil { + return err + } + return domains.Flush(t.Context(), tx) + })) + + tx, err := db.BeginTemporalRo(t.Context()) + require.NoError(t, err) + defer tx.Rollback() + stateVersion, err := tx.ReadSequence(string(kv.PlainStateVersion)) + require.NoError(t, err) + + cache.OnNewBlock(&remoteproto.StateChangeBatch{ + StateVersionId: stateVersion + 1, + ChangeBatch: []*remoteproto.StateChange{{ + Direction: remoteproto.Direction_FORWARD, + Changes: []*remoteproto.AccountChange{{ + Action: remoteproto.Action_UPSERT, + Address: gointerfaces.ConvertAddressToH160(addr), + Data: announcedData, + }}, + }}, + }) + require.Zero(t, cache.Len()) + + view, err := cache.View(t.Context(), tx) + require.NoError(t, err) + data, err := view.Get(addr[:]) + require.NoError(t, err) + require.Equal(t, committedData, data) +} From 571f398952428f521916c4aa13e227bf02115908 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 16:55:10 +0200 Subject: [PATCH 38/45] rpc: resolve log block hashes on committed view --- rpc/jsonrpc/eth_receipts.go | 2 +- rpc/jsonrpc/overlay_race_test.go | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/rpc/jsonrpc/eth_receipts.go b/rpc/jsonrpc/eth_receipts.go index f0408c23d64..c675a5e9a7b 100644 --- a/rpc/jsonrpc/eth_receipts.go +++ b/rpc/jsonrpc/eth_receipts.go @@ -144,7 +144,7 @@ func exceedsLogQueryLimit(crit filters.FilterCriteria, limit int) bool { // filters — see rpchelper.GetBlockNumber): callers scan logs through the same tx. func (api *BaseAPI) resolveLogsRange(ctx context.Context, tx kv.Tx, crit filters.FilterCriteria, checkFuture bool) (begin, end uint64, err error) { if crit.BlockHash != nil { - block, err := api.blockByHashWithSenders(ctx, tx, *crit.BlockHash) + block, err := api._blockReader.BlockByHash(ctx, tx, *crit.BlockHash) if err != nil { return 0, 0, err } diff --git a/rpc/jsonrpc/overlay_race_test.go b/rpc/jsonrpc/overlay_race_test.go index e761c67ac27..322bd02641a 100644 --- a/rpc/jsonrpc/overlay_race_test.go +++ b/rpc/jsonrpc/overlay_race_test.go @@ -19,6 +19,7 @@ package jsonrpc import ( "bytes" "context" + "fmt" "strconv" "testing" @@ -43,6 +44,7 @@ import ( "github.com/erigontech/erigon/node/gointerfaces/txpoolproto" "github.com/erigontech/erigon/node/shards" "github.com/erigontech/erigon/rpc" + "github.com/erigontech/erigon/rpc/filters" "github.com/erigontech/erigon/rpc/rpchelper" ) @@ -312,6 +314,17 @@ func TestDebugAccountAt_OverlayHeadHash_CommittedView(t *testing.T) { require.Nil(t, result) } +func TestGetLogsBlockHashUsesCommittedView(t *testing.T) { + t.Parallel() + base, m, overlayHeader := newOverlayAheadTestAPI(t) + api := newEthApiForTest(base, m.DB, nil, nil) + hash := overlayHeader.Hash() + + logs, err := api.GetLogs(m.Ctx, filters.FilterCriteria{BlockHash: &hash}) + require.EqualError(t, err, fmt.Sprintf("block not found: %x", hash)) + require.Nil(t, logs) +} + // TestTxPoolContentFrom_UsesOverlayHead pins that txpool_contentFrom reads the // current header through the block overlay, matching TestTxPoolContent_UsesOverlayHead. func TestTxPoolContentFrom_UsesOverlayHead(t *testing.T) { From a20207399579a06df2745624970dd264d8fb7f7e Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 17:01:35 +0200 Subject: [PATCH 39/45] rpc: allow genesis state proofs --- rpc/jsonrpc/eth_call.go | 2 -- rpc/jsonrpc/eth_call_test.go | 5 +++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/rpc/jsonrpc/eth_call.go b/rpc/jsonrpc/eth_call.go index a723bc82d05..996be6f89c4 100644 --- a/rpc/jsonrpc/eth_call.go +++ b/rpc/jsonrpc/eth_call.go @@ -433,8 +433,6 @@ func (api *APIImpl) GetProof(ctx context.Context, address common.Address, storag blockNumber, _, isLatest, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, roTx, api._blockReader, nil) if err != nil { return nil, err - } else if blockNumber == 0 { - return nil, errors.New("block not found") } err = api.BaseAPI.checkPruneHistory(ctx, roTx, blockNumber) diff --git a/rpc/jsonrpc/eth_call_test.go b/rpc/jsonrpc/eth_call_test.go index c5f66d4b839..53a40af37f4 100644 --- a/rpc/jsonrpc/eth_call_test.go +++ b/rpc/jsonrpc/eth_call_test.go @@ -310,6 +310,11 @@ func TestGetProof(t *testing.T) { stateVal uint64 expectedErr string }{ + { + name: "genesisAccount", + addr: bankAddr, + blockNum: 0, + }, { name: "currentBlockNoState", addr: contractAddr, From b0146043ba3fe4986dc98ff9aa546f65eab838fe Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 17:08:55 +0200 Subject: [PATCH 40/45] rpc: restore genesis proof rejection Reverts a20207399579a06df2745624970dd264d8fb7f7e. Resolving a canonical block and supporting its state boundary in eth_getProof are separate concerns; this implementation does not support proofs at block zero. --- rpc/jsonrpc/eth_call.go | 2 ++ rpc/jsonrpc/eth_call_test.go | 5 ----- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/rpc/jsonrpc/eth_call.go b/rpc/jsonrpc/eth_call.go index 996be6f89c4..a723bc82d05 100644 --- a/rpc/jsonrpc/eth_call.go +++ b/rpc/jsonrpc/eth_call.go @@ -433,6 +433,8 @@ func (api *APIImpl) GetProof(ctx context.Context, address common.Address, storag blockNumber, _, isLatest, err := rpchelper.GetCanonicalBlockNumber(ctx, blockNrOrHash, roTx, api._blockReader, nil) if err != nil { return nil, err + } else if blockNumber == 0 { + return nil, errors.New("block not found") } err = api.BaseAPI.checkPruneHistory(ctx, roTx, blockNumber) diff --git a/rpc/jsonrpc/eth_call_test.go b/rpc/jsonrpc/eth_call_test.go index 53a40af37f4..c5f66d4b839 100644 --- a/rpc/jsonrpc/eth_call_test.go +++ b/rpc/jsonrpc/eth_call_test.go @@ -310,11 +310,6 @@ func TestGetProof(t *testing.T) { stateVal uint64 expectedErr string }{ - { - name: "genesisAccount", - addr: bankAddr, - blockNum: 0, - }, { name: "currentBlockNoState", addr: contractAddr, From d59b0f7e9b8b891d901ac5eaec11f14b188c61c7 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 17:18:08 +0200 Subject: [PATCH 41/45] rpc: handle missing eth_getProof header --- rpc/jsonrpc/eth_call.go | 3 +++ rpc/jsonrpc/eth_call_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/rpc/jsonrpc/eth_call.go b/rpc/jsonrpc/eth_call.go index a723bc82d05..68559ccad6e 100644 --- a/rpc/jsonrpc/eth_call.go +++ b/rpc/jsonrpc/eth_call.go @@ -472,6 +472,9 @@ func (api *APIImpl) getProof(ctx context.Context, roTx kv.TemporalTx, address co if err != nil { return nil, err } + if header == nil { + return nil, fmt.Errorf("header not found for block %d", blockNumber) + } domains, err := execctx.NewSharedDomains(ctx, roTx, log.New(), execctx.WithoutDeferredBranchUpdates(), execctx.WithSequentialCommitment()) if err != nil { diff --git a/rpc/jsonrpc/eth_call_test.go b/rpc/jsonrpc/eth_call_test.go index c5f66d4b839..27657f3c848 100644 --- a/rpc/jsonrpc/eth_call_test.go +++ b/rpc/jsonrpc/eth_call_test.go @@ -39,6 +39,7 @@ import ( "github.com/erigontech/erigon/common/crypto" "github.com/erigontech/erigon/common/hexutil" "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/dbservices" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/kvcache" "github.com/erigontech/erigon/db/kv/rawdbv3" @@ -431,6 +432,36 @@ func TestGetProof(t *testing.T) { } } +type missingHeaderBlockReader struct { + dbservices.FullBlockReader +} + +func (missingHeaderBlockReader) HeaderByNumber(context.Context, kv.Getter, uint64) (*types.Header, error) { + return nil, nil +} + +func TestGetProofMissingHeader(t *testing.T) { + previousSchema := statecfg.Schema + statecfg.EnableHistoricalCommitment() + t.Cleanup(func() { + statecfg.Schema = previousSchema + }) + + m, bankAddr, _, _ := chainWithDeployedContract(t) + base := newBaseApiForTest(m) + base._blockReader = missingHeaderBlockReader{FullBlockReader: base._blockReader} + api := newEthApiForTest(base, m.DB, nil, nil) + + proof, err := api.GetProof( + context.Background(), + bankAddr, + nil, + bnhPtr(rpc.BlockNumberOrHashWithNumber(6)), + ) + require.EqualError(t, err, "header not found for block 6") + require.Nil(t, proof) +} + func TestGetProofPinsReadSnapshot(t *testing.T) { previousSchema := statecfg.Schema statecfg.EnableHistoricalCommitment() From 952e7bd2e019d0c8d861163b845789b171385e56 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 16 Jul 2026 15:00:41 +0200 Subject: [PATCH 42/45] docs: align --state.cache zero-value text with the flag help rpc-daemon.md still described the removed NewSimple fallback ("fall back to a non-versioned last-block cache"); use the config.go wording and regenerate llms-full.txt via generate-llms.py. --- docs/site/docs/fundamentals/modules/rpc-daemon.md | 2 +- docs/site/static/llms-full.txt | 2 +- llms-full.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/site/docs/fundamentals/modules/rpc-daemon.md b/docs/site/docs/fundamentals/modules/rpc-daemon.md index 735bbb907ed..019b52e0c3a 100644 --- a/docs/site/docs/fundamentals/modules/rpc-daemon.md +++ b/docs/site/docs/fundamentals/modules/rpc-daemon.md @@ -117,7 +117,7 @@ Flags: --rpc.txfeecap float Sets a cap on transaction fee (in ether) that can be sent via the RPC APIs (0 = no cap) (default 1) --socket.enabled Enable IPC server --socket.url string IPC server listening url. prefix supported are tcp, unix (default "unix:///var/run/erigon.sock") - --state.cache string Amount of data to store in the version-keyed StateCache (an equally-sized code cache is budgeted on top). Set 0 to fall back to a non-versioned last-block cache (default "0MB") + --state.cache string Amount of data to store in the version-keyed StateCache (an equally-sized code cache is budgeted on top). Set 0 to disable entry retention while preserving snapshot-consistent reads (default "0MB") --tls.cacert string CA certificate for client side TLS handshake for GRPC --tls.cert string certificate for client side TLS handshake for GRPC --tls.key string key file for client side TLS handshake for GRPC diff --git a/docs/site/static/llms-full.txt b/docs/site/static/llms-full.txt index 80d29142d5e..73224209712 100644 --- a/docs/site/static/llms-full.txt +++ b/docs/site/static/llms-full.txt @@ -4101,7 +4101,7 @@ Flags: --rpc.txfeecap float Sets a cap on transaction fee (in ether) that can be sent via the RPC APIs (0 = no cap) (default 1) --socket.enabled Enable IPC server --socket.url string IPC server listening url. prefix supported are tcp, unix (default "unix:///var/run/erigon.sock") - --state.cache string Amount of data to store in the version-keyed StateCache (an equally-sized code cache is budgeted on top). Set 0 to fall back to a non-versioned last-block cache (default "0MB") + --state.cache string Amount of data to store in the version-keyed StateCache (an equally-sized code cache is budgeted on top). Set 0 to disable entry retention while preserving snapshot-consistent reads (default "0MB") --tls.cacert string CA certificate for client side TLS handshake for GRPC --tls.cert string certificate for client side TLS handshake for GRPC --tls.key string key file for client side TLS handshake for GRPC diff --git a/llms-full.txt b/llms-full.txt index 80d29142d5e..73224209712 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -4101,7 +4101,7 @@ Flags: --rpc.txfeecap float Sets a cap on transaction fee (in ether) that can be sent via the RPC APIs (0 = no cap) (default 1) --socket.enabled Enable IPC server --socket.url string IPC server listening url. prefix supported are tcp, unix (default "unix:///var/run/erigon.sock") - --state.cache string Amount of data to store in the version-keyed StateCache (an equally-sized code cache is budgeted on top). Set 0 to fall back to a non-versioned last-block cache (default "0MB") + --state.cache string Amount of data to store in the version-keyed StateCache (an equally-sized code cache is budgeted on top). Set 0 to disable entry retention while preserving snapshot-consistent reads (default "0MB") --tls.cacert string CA certificate for client side TLS handshake for GRPC --tls.cert string certificate for client side TLS handshake for GRPC --tls.key string key file for client side TLS handshake for GRPC From 120745959ff8dd13eeeba71c19ee64d139720d25 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 16 Jul 2026 15:19:44 +0200 Subject: [PATCH 43/45] db/kv/kvcache: skip cache machinery at zero budget At a zero budget the eviction loop drains every insert straight back out, so Get/GetCode took the global lock twice and churned the btree and eviction list per read-through, and OnNewBlock fed-then-evicted every batch entry. Skip the lookup and the add when the budget is zero; reads fall through to the caller's tx snapshot as before. Zero budget is the standalone rpcdaemon default until #22269 raises it. Behavior-preserving: TestZeroBudgetRetainsNothing pins the contract (nothing retained, reads resolve on the tx snapshot rather than announced batch data) and was confirmed green before the change too. --- db/kv/kvcache/cache.go | 20 +++++++-- db/kv/kvcache/cache_test.go | 87 +++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/db/kv/kvcache/cache.go b/db/kv/kvcache/cache.go index c4540e90e5d..11d4e8476f9 100644 --- a/db/kv/kvcache/cache.go +++ b/db/kv/kvcache/cache.go @@ -358,7 +358,13 @@ func (c *Coherent) getFromCache(k []byte, id uint64, domain kv.Domain) (*Element } func (c *Coherent) Get(k []byte, tx kv.TemporalTx, id uint64) (v []byte, err error) { //TODO: Get must accept from user Domain parameter - it, r := c.getFromCache(k, id, kv.AccountsDomain) + var it *Element + var r *CoherentRoot + // A zero budget retains nothing: skip the lookup and its global lock; + // leaving r nil also skips the add below. + if c.cfg.CacheSize != 0 { + it, r = c.getFromCache(k, id, kv.AccountsDomain) + } if it != nil { c.hits.Inc() @@ -390,7 +396,12 @@ func (c *Coherent) Get(k []byte, tx kv.TemporalTx, id uint64) (v []byte, err err } func (c *Coherent) GetCode(k []byte, tx kv.TemporalTx, id uint64) (v []byte, err error) { - it, r := c.getFromCache(k, id, kv.CodeDomain) + var it *Element + var r *CoherentRoot + // see Get + if c.cfg.CodeCacheSize != 0 { + it, r = c.getFromCache(k, id, kv.CodeDomain) + } if it != nil { c.codeHits.Inc() @@ -431,7 +442,8 @@ func (c *Coherent) add(k, v []byte, r *CoherentRoot, id uint64) *Element { // Non-latest roots bypass eviction accounting, so growing them would be // unbounded (e.g. with no state-change stream feeding OnNewBlock); the // caller's tx read is authoritative for its snapshot, skip caching. - if c.latestStateVersionID != id { + // A zero budget would evict the entry immediately — also skip. + if c.latestStateVersionID != id || c.cfg.CacheSize == 0 { return it } replaced, _ := r.cache.Set(it) @@ -450,7 +462,7 @@ func (c *Coherent) add(k, v []byte, r *CoherentRoot, id uint64) *Element { func (c *Coherent) addCode(k, v []byte, r *CoherentRoot, id uint64) *Element { it := &Element{K: k, V: v} // see add - if c.latestStateVersionID != id { + if c.latestStateVersionID != id || c.cfg.CodeCacheSize == 0 { return it } replaced, _ := r.codeCache.Set(it) diff --git a/db/kv/kvcache/cache_test.go b/db/kv/kvcache/cache_test.go index 57f1e05186d..94711d1a93d 100644 --- a/db/kv/kvcache/cache_test.go +++ b/db/kv/kvcache/cache_test.go @@ -404,6 +404,93 @@ func TestNonLatestViewReadsAreNotCached(t *testing.T) { require.NoError(err) } +// A zero budget must never retain entries — batch-fed or read-through, even at +// the latest version — and reads must resolve on the caller's tx snapshot, +// never on announced batch data. +func TestZeroBudgetRetainsNothing(t *testing.T) { + require, ctx := require.New(t), t.Context() + cfg := DefaultCoherentConfig + cfg.CacheSize = 0 + cfg.CodeCacheSize = 0 + cfg.NewBlockWait = 0 + c := New(cfg) + + db := temporaltest.NewTestDB(t, datadir.New(t.TempDir())) + addr, loc := [20]byte{1}, [32]byte{2} + committedAcc := accounts.Account{Nonce: 1, Balance: *uint256.NewInt(11), CodeHash: accounts.EmptyCodeHash} + committedAccEnc := accounts.SerialiseV3(&committedAcc) + committedCode := []byte{0x60, 0x01} + committedSlot := []byte{7} + storageKey := append(addr[:], loc[:]...) + + err := db.UpdateTemporal(ctx, func(tx kv.TemporalRwTx) error { + d, err := execctx.NewSharedDomains(ctx, tx, log.New()) + if err != nil { + return err + } + defer d.Close() + if err := d.DomainPut(kv.AccountsDomain, tx, addr[:], committedAccEnc, 0, nil); err != nil { + return err + } + if err := d.DomainPut(kv.CodeDomain, tx, addr[:], committedCode, 0, nil); err != nil { + return err + } + if err := d.DomainPut(kv.StorageDomain, tx, storageKey, committedSlot, 0, nil); err != nil { + return err + } + return d.Flush(ctx, tx) + }) + require.NoError(err) + + err = db.ViewTemporal(ctx, func(tx kv.TemporalTx) error { + stateVersion, err := tx.ReadSequence(string(kv.PlainStateVersion)) + require.NoError(err) + + announcedAcc := committedAcc + announcedAcc.Nonce = 2 + c.OnNewBlock(&remoteproto.StateChangeBatch{ + StateVersionId: stateVersion, + ChangeBatch: []*remoteproto.StateChange{{ + Direction: remoteproto.Direction_FORWARD, + Changes: []*remoteproto.AccountChange{{ + Action: remoteproto.Action_UPSERT_CODE, + Address: gointerfaces.ConvertAddressToH160(addr), + Data: accounts.SerialiseV3(&announcedAcc), + Code: []byte{0x60, 0x02}, + StorageChanges: []*remoteproto.StorageChange{{ + Location: gointerfaces.ConvertHashToH256(loc), + Data: []byte{42}, + }}, + }}, + }}, + }) + + cacheView, err := c.View(ctx, tx) + require.NoError(err) + view := cacheView.(*CoherentView) + require.Equal(c.latestStateVersionID, view.stateVersionID) + + v, err := c.Get(addr[:], tx, view.stateVersionID) + require.NoError(err) + require.Equal(committedAccEnc, v) + + v, err = c.Get(storageKey, tx, view.stateVersionID) + require.NoError(err) + require.Equal(committedSlot, v) + + code, err := c.GetCode(addr[:], tx, view.stateVersionID) + require.NoError(err) + require.Equal(committedCode, code) + + require.Zero(c.roots[view.stateVersionID].cache.Len()) + require.Zero(c.roots[view.stateVersionID].codeCache.Len()) + require.Zero(c.stateEvict.Len()) + require.Zero(c.codeEvict.Len()) + return nil + }) + require.NoError(err) +} + func TestAPI(t *testing.T) { require := require.New(t) From 2b9f03c7d68585428ee21be8807eb04676370880 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 16 Jul 2026 15:27:06 +0200 Subject: [PATCH 44/45] rpc/jsonrpc: resolve getLogs block-hash filter via HeaderNumber resolveLogsRange only needs the block number, but BlockByHash decodes the full body and recovers senders on every eth_getLogs/overlay_getLogs call with a blockHash filter. A header without a body (sync window, pruned bodies) now resolves and hits the clearer downstream guards (latest-executed check, checkReceiptsAvailable) instead of a generic "block not found". --- rpc/jsonrpc/eth_receipts.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/rpc/jsonrpc/eth_receipts.go b/rpc/jsonrpc/eth_receipts.go index c675a5e9a7b..4c58f381218 100644 --- a/rpc/jsonrpc/eth_receipts.go +++ b/rpc/jsonrpc/eth_receipts.go @@ -144,16 +144,14 @@ func exceedsLogQueryLimit(crit filters.FilterCriteria, limit int) bool { // filters — see rpchelper.GetBlockNumber): callers scan logs through the same tx. func (api *BaseAPI) resolveLogsRange(ctx context.Context, tx kv.Tx, crit filters.FilterCriteria, checkFuture bool) (begin, end uint64, err error) { if crit.BlockHash != nil { - block, err := api._blockReader.BlockByHash(ctx, tx, *crit.BlockHash) + number, err := api._blockReader.HeaderNumber(ctx, tx, *crit.BlockHash) if err != nil { return 0, 0, err } - if block == nil { + if number == nil { return 0, 0, fmt.Errorf("block not found: %x", *crit.BlockHash) } - - num := block.NumberU64() - return num, num, nil + return *number, *number, nil } latest, _, _, err := rpchelper.GetBlockNumber(ctx, rpc.BlockNumberOrHashWithNumber(rpc.LatestExecutedBlockNumber), tx, api._blockReader, nil) From 0fb6db2fac5c42435f727698cfd81e39abdb58c9 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 16 Jul 2026 16:03:35 +0200 Subject: [PATCH 45/45] rpc/rpchelper: clarify the GetBlockNumber filters contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tags resolve against the view tx exposes — nil filters does not force the committed view; wrap-once callers pass an overlay tx with nil filters. filters only controls the internal overlay wrap and whether "pending" may resolve via LastPendingBlock. --- rpc/rpchelper/helper.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/rpc/rpchelper/helper.go b/rpc/rpchelper/helper.go index 6b0277caaff..34397f3fbd6 100644 --- a/rpc/rpchelper/helper.go +++ b/rpc/rpchelper/helper.go @@ -58,12 +58,14 @@ func CheckBlockExecuted(tx kv.Tx, blockNumber uint64) error { // GetBlockNumber resolves a block number, hash, or tag to a concrete block number and hash. // -// filters controls which view tags resolve against. Pass the API's Filters to -// resolve through the block overlay, which includes a head whose commit is -// still in flight. Pass nil to resolve purely on the committed view of tx — -// required when the caller then scans data through the same plain tx, so the -// bounds and the scan agree; "pending" then falls back to the latest executed -// block. +// Tags resolve against the view tx exposes. Passing the API's Filters +// additionally wraps tx in the block overlay (which includes a head whose +// commit is still in flight) and lets "pending" resolve via LastPendingBlock. +// With nil filters tx is used exactly as passed — a plain tx for +// committed-view resolution, required when the caller then scans data through +// that same tx so the bounds and the scan agree, or a tx the caller already +// overlay-wrapped to pin one view for all its reads; "pending" then falls +// back to the latest executed block. func GetBlockNumber(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash, tx kv.Tx, br dbservices.FullBlockReader, filters *Filters) (uint64, common.Hash, bool, error) { bn, bh, latest, found, err := _GetBlockNumber(ctx, blockNrOrHash.RequireCanonical, blockNrOrHash, tx, br, filters) if err != nil {