rpc: resolve eth_feeHistory on the block overlay view - #22987
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes an inconsistency in JSON-RPC head resolution during the block-overlay publish/commit window: eth_feeHistory (via the gas oracle) could resolve its head from the committed view while other RPC paths (e.g. eth_blockNumber) report the overlay head, causing fee history windows to lag and intermittently fail RPC tests.
Changes:
- Pin a single overlay-aware temporal read view inside
GasPriceOracleBackendso head resolution and per-block sampling are consistent. - Ensure
GasPriceOracleBackend.Fork()uses the same overlay-aware constructor, so fee-history sampling paths that run on the forked tx also see the overlay. - Add overlay race regression tests covering fee history with and without reward percentiles, and update the test harness to write the forkchoice head marker into the overlay.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
rpc/jsonrpc/eth_system.go |
Wraps gas oracle backend tx with WithTemporalOverlay (including forked tx) to make eth_feeHistory consistent with overlay head publication. |
rpc/jsonrpc/overlay_race_test.go |
Adds forkchoice-head marker setup and new regression tests asserting fee history resolves "latest" on the overlay head. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
yperbasis
left a comment
There was a problem hiding this comment.
Requesting changes. Findings by severity; the first one is reproduced locally on this branch.
High (blocking) — in-flight block receipts silently read as zero
Read views keep DomainReader = sd (db/state/execctx/domain_shared.go:720). The background-commit teardown (execution/execmodule/forkchoice.go:731-736) calls bgSD.Close() → sd.mem.Close() → ClearRam() (db/state/temporal_mem_batch.go:654), which empties the domain maps while RPC views may still hold them. Reproduced: after sd.Close(), a view created earlier returns ok=false, err=nil for an in-flight ReceiptDomain key. The fallback is the reader's own tx — pre-commit for requests started during the publish window — and ReceiptAsOf zero-fills misses (db/rawdb/rawtemporaldb/accessors_receipt.go:42-60). So GetReceiptsGasUsed returns GasUsed=0 for every tx of the head block and the reward percentiles are silently wrong. The window is realistic: a percentile fan-out over many blocks often still runs when the commit finishes.
The class predates this PR (the receipts generator wraps its tx since #22511), but this PR makes the oracle resolve the in-flight head and then sample its receipts, so it leans on the missing lifetime guarantee much harder.
Suggested fix (can be a prerequisite PR in execctx/execmodule): don't clear domain RAM under a published SD — e.g. a teardown variant that closes writer resources but skips ClearRam, leaving the maps to GC once Events and the last view drop the pointer (GetAsOf touches only domains/storage/unwindChangeset, read-only after publication). Refcounting is the heavier alternative.
Medium — Fork can lose the resolved head → whole request fails
Fork opens its tx first and resolves LatestSD() afterwards (via the constructor). If the commit → PublishOverlay(nil) interval lands in between, the forked backend has neither the overlay nor the committed head block. On the header-only path headerByNumber propagates BlockNotFoundErr unfiltered (rpc/jsonrpc/eth_api.go:397-400), so the whole eth_feeHistory request fails with -32000 block not found: N; on the rewards path the head slot is silently dropped (eth_api.go:301-303). Rare in production (the interval includes an fsync'd commit, so the fork goroutine must be descheduled for >1ms between opening and wrapping), but it turns a stale read into a failed request, and it is new with this PR — before, the window never included the in-flight block.
Suggested fix: capture the overlay once in NewGasPriceOracleBackend and let Fork wrap its fresh tx with the parent's overlay instance instead of re-resolving LatestSD(). Plain-table reads on a closed overlay are safe (memStore close is a no-op on the data). Note this makes the high-severity item easier to hit — fork views then always carry the SD's DomainReader — so both fixes should land together.
Low — rewards test never exercises receipts
TestFeeHistory_OverlayHeadWithRewards uses an empty-body overlay block, so it hits the zero-reward guard (rpc/gasprice/feehistory.go:186) and never reads receipt data. Extend it to a block with transactions plus receipt-domain entries and assert non-zero gas — this is the test that pins the high-severity fix.
Low (non-blocking follow-up) — nested overlay views
With b.tx now a view, downstream re-wraps create view-over-view over the same overlay, sharing one *sync.RWMutex: the receipts generator (rpc/jsonrpc/receipts/receipts_generator.go:214,459,691), headerByNumber (rpc/jsonrpc/eth_api.go:407), and fillFeeDefaults receiving the already-wrapped tx (rpc/jsonrpc/eth_fill_transaction.go:118). GetOne/Has hold RLock while falling back to m.db, so nested views take a recursive read lock. Safe today — nothing write-locks a published overlay — but that invariant is enforced nowhere; a writer added later deadlocks. Making WithOverlay/WithTemporalOverlay idempotent would remove this and the redundant double lookups.
|
High: Will be fixed in a separate PR #23046, as suggested. This PR stays a draft until that one is merged. Medium: Fixed as suggested: the backend captures the overlay once at construction and Fork reuses that instance instead of re-resolving LatestSD(). Pinned by TestGasPriceOracle_ForkKeepsOverlayAfterUnpublish, red before the fix. Low1: Extended: the overlay block now has two txs with different tips plus their receipt-domain entries, and the test asserts a low percentile returns the cheap tx's tip — verified red with zero-filled receipt gas. Low2: (non-blocking follow-up) Agreed, planned as a follow-up PR |
…gontech#23046) Fixes the high-severity finding from the erigontech#22987 review. **Problem.** RPC read views keep a `DomainReader` pointing at the published SD's in-memory domain maps. The background-commit teardown (`bgSD.Close()` → `mem.Close()` → `ClearRam()`) emptied those maps while readers were still using them. A receipt read of the in-flight block then missed silently, fell back to the request's pre-commit tx, and `ReceiptAsOf` zero-filled the miss: `GetReceiptsGasUsed` returned `GasUsed=0` for every tx of the head block, and `eth_feeHistory` reward percentiles were silently wrong. Latest-state reads could likewise fall back to the previous block's state mid-request. **Fix.** `TemporalMemBatch.Close` no longer clears the in-memory domain maps: they go to the GC once the last reference drops. With that, `ClearRam` had no production caller left and is removed entirely — the batch has a single lifetime (write, maybe publish, close-and-drop) and no API can clear the maps under readers. The one internal test that used clear-and-reuse now mirrors what `cmd/integration` actually does today: a fresh `SharedDomains` per batch. **Tests.** New `TestClose_KeepsDomainRamForReaders` (red before the fix, green after). One existing assert updated: a post-teardown view now keeps serving the published head instead of falling back to its own tx. erigontech#22987 (draft) depends on this PR: pinning the overlay across `Fork` makes this window easier to hit, so that PR stays a draft until this one is merged.
yperbasis
left a comment
There was a problem hiding this comment.
High
-
Downstream helpers override the pinned overlay. BaseAPI and receipts helpers re-resolve the live overlay on every call (
rpc/rpchelper/helper.go:87,receipts/receipts_generator.go:691,eth_api.go:333) and layer it over the pinned view, so a publish during the request still mixes views. Worst case, after a same-height reorg,ReceiptAsOfreads the new fork's cumulative gas at the old block's txNums (ReceiptDomainis exempt from theinMemHistoryReadsguard): wrongGasUseddeltas (uint underflow possible) and reward percentiles from another fork. Suggested fix: makeWithOverlay/WithTemporalOverlayno-ops when the passed tx already carries an overlay view. A same-instance check is not enough — it would still wrap pinned view A with live overlay B. -
Overlay results poison
FeeHistoryCache. The cache key is{blockNumber, percentiles}— no hash, no reorg/unwind invalidation (rpc/gasprice/feehistory.go:63). With this PR, "latest" can resolve to a not-yet-committed overlay block. If the background commit fails, or a same-height sibling lands, the dead block's fees keep being served until LRU eviction. Fix: skip caching blocks that come from the overlay (or near head), or add the canonical hash to the key.
Medium
-
Overlay capture and tx open are not atomic. The overlay is captured after the caller has opened the tx (
rpc/jsonrpc/eth_system.go:504). If commit +PublishOverlay(nil)land in between, the request sees neither the overlay nor the committed block (stale head). In the opposite direction, the pinned overlay can be one FCU ahead of the tx snapshot, leaving a block visible in neither layer. Capture the overlay atomically with tx acquisition — capture-before-open alone only flips the window. -
eth_gasPricestill mixes heads. The tip comes from the pinned oracle, but the baseFee addend re-resolves the live overlay (rpc/jsonrpc/eth_system.go:273). One-line fix: read the current header through the pinnedb.tx. -
fillFeeDefaultsdouble-wraps the overlay.FillTransaction/signTransactionwrap the tx (eth_fill_transaction.go:61) andnewGasOraclenow wraps it again: view-over-view, doubled memTx probes, and diverging heads if the overlay flips between the two wraps. Pass the rawdbTxintonewGasOracle, or rely on the no-op rule from point 1.
Low / latent
-
Unenforced write-freeze on the published overlay. All read views share one
*sync.RWMutex(newReadViewMutcopies the parent'smu) andGetOneholdsRLockacross the fallback into the inner view — a recursiveRLock. This is safe only while nothing writes to a published overlay, and nothing enforces that. Consider a runtime assert (e.g. rejectPutafter publish/Close). Related:Rollback/CommitnilstatelessCursorswithout takingmu(memory_mutation.go:584), unlike the other writers. -
Test gap.
TestGasPriceOracle_ForkKeepsOverlayAfterUnpublishcallsPublishOverlay(nil)but neverdoms.Close(); production always pairs them. Adddoms.Close()beforebackend.Fork()so the test pins the invariant the PR depends on. -
Overlay-view tax on committed history. Every gas-oracle read now takes the shared
RLockplus a guaranteed memTx miss (doubled by the re-wraps) even when all requested blocks are committed. The no-op rule from point 1 removes the doubling; routing reads below the overlay's base block through the raw tx would remove the rest.
Notes
- Longer term, a per-request pin (e.g. at tx acquisition) would fix this class once —
rpc/jsonrpchas ~44 independent overlay re-resolves with the same exposure (eth_getLogs,eth_simulateV1, trace workers). - Nit: the pin-once rationale is repeated at three sites; one canonical place is enough.
|
@yperbasis: All points addressed except point 6.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (2)
rpc/jsonrpc/eth_system.go:529
- This condition does not guarantee that the transaction and overlay form a consistent snapshot. It explicitly accepts a changed overlay on the third attempt, and pointer equality also misses a complete
nil -> overlay -> nilpublish/commit cycle duringBeginTemporalRo; in either case the returned transaction can predate the commit while the returned overlay is nil or belongs to a newer head, recreating the missing-block window this helper is meant to close. Track a monotonically increasing publication generation together with the overlay and retry until the generation is unchanged; do not return a mismatched pair after a fixed attempt count.
if current == overlay || attempt == maxAttempts {
return tx, current, nil
rpc/jsonrpc/eth_system.go:506
- The supplied transaction may already be pinned to overlay A, as in
FillTransaction, but this constructor independently records the currently published overlay B.withOverlaythen keeps the parent on A becauseCarriesOverlayView(tx)is true, whileForkwraps its fresh transactions with B, so one oracle operation can resolve its head from A and sample blocks from B. Preserve the originating overlay identity in overlay read views (or pass the already-captured overlay explicitly at every call site) instead of samplingLatestOverlayhere.
func NewGasPriceOracleBackend(db kv.TemporalRoDB, tx kv.TemporalTx, baseApi *BaseAPI) *GasPriceOracleBackend {
return newGasPriceOracleBackendPinned(db, tx, baseApi, baseApi.filters.LatestOverlay())
yperbasis
left a comment
There was a problem hiding this comment.
Combined findings from two independent review passes; the first, second and fourth items were also reproduced with regression tests. Line numbers refer to head 5856794.
High
-
Overlay reorgs defeat the number-keyed fee-history cache. Cacheability is only
blockNumber <= committed head(rpc/jsonrpc/eth_system.go:542,rpc/gasprice/feehistory.go:392), but an overlay can replace or unwind blocks at or below that height. Two directions: a cached committed block keeps being served after an overlay sibling replaces the same height (reproduced: the old base fee is returned), and during a commit backlog uncommitted overlay siblings below the limit get cached. Fix: key the cache by block hash (structural — also removesCacheableBlockLimitfromOracleBackend), or keep the boundary below the earliest overlay-modified height. -
FillTransactionbuilds an oracle with two different overlay pins. The tx is wrapped with overlay A (rpc/jsonrpc/eth_fill_transaction.go:61), butNewGasPriceOracleBackendre-capturesLatestOverlay()= B (eth_system.go:506), sob.txstays on A whileForkwraps goroutine txs with B (eth_system.go:566). Reproduced: parent and fork head hashes differ. Pass the overlay together with the wrapped tx (or let the view expose its overlay identity) so the backend and its forks share one pin. -
The
overlay == nilpin is not sticky. A raw tx carries noCarriesOverlayViewmarker (eth_system.go:550), so downstream helpers re-resolve the live overlay on every call (rpc/rpchelper/helper.go:87,rpc/jsonrpc/eth_api.go:333,:407). An overlay published mid-request leaks in:eth_gasPricecan sum a tip sampled at head N+1 with a base fee read at head N. Make the nil pin explicit so the whole request stays on one view.
Medium
-
The last
beginTxWithOverlayattempt returns the(tx, overlay)pair it has just proven inconsistent (eth_system.go:528), which can produce a gapped view — a block visible in neither the tx snapshot nor the overlay (reproduced: stale head returned). Also, the pointer-equality check treats nil==nil as stable, so a full publish/commit/unpublish cycle between the two captures passes on the first attempt. Return an error (or keep retrying until stable), and compare a monotonic publish sequence number instead of pointer identity — that closes both holes. -
The "
rawTxis the caller's tx before the overlay wrap" contract onCacheableBlockLimit(eth_system.go:542) is unenforced and already broken: theFillTransactionpath stores a wrapped view in it. Harmless today (that path never reachesFeeHistory), but on a wrapped txGetLatestBlockNumberresolves the overlay's uncommitted forkchoice head, so the cache limit would include in-flight blocks. Enforce or assert the contract.
Low / follow-ups
-
Two sources remain unpinned in a request meant to hold one consistent view (both pre-existing):
Forkopens fresh base txs whose snapshots can postdate a committed reorg (eth_system.go:558), and the pending block comes from the live mining cache (feehistory.go:230). -
~45 call sites keep the non-atomic
BeginTemporalRo-then-WithOverlaypattern with the same race window this PR closes for three endpoints — includingBlobBaseFee(eth_system.go:350),BaseFee(:377) andBlockNumber(:196) in the same file. PromotebeginTxWithOverlayto a shared helper onBaseAPI/Filters; fine as a follow-up. -
Smaller cleanups: the skip-then-wrap sequence exists in three copies (
rpc/rpchelper/filters.go:1177,:1211,eth_system.go:549) — extract one helper.readViewduplicates thememDb == nilpredicate (db/kv/membatchwithdb/memory_mutation.go:61).CarriesOverlayView(memory_mutation.go:1091) is a concrete-type switch that any future tx wrapper silently defeats — a marker-interface method would fail loudly instead. -
Nits: drop the call-site sentence ("Only the fee-history path pays for the resolution.") from the
CacheableBlockLimitdoc comment (eth_system.go:537); remove theCo-Authored-Bytrailer (e035aa3) and the "Generated with Claude Code" footer from the description — repo convention (CLAUDE.md): don't sign commits or PRs; the description's "stays a draft until that one is merged" sentence is stale now that #23046 is merged.
|
All points addressed except point 6 (pre-existing, as noted) and the call-site migration of point 7 (follow-up, as agreed). Every behavioral fix is pinned by a regression test that was red before it; point 8 is a pure refactor covered by the existing suite. The branch is rebased linearly onto main; lint and the full unit suite are green High:
Medium:
Low:
|
eth_feeHistory resolved its head on the committed view while eth_blockNumber publishes the overlay head, so during the forkchoice flush+commit window the whole fee window lagged the published head by one block or more (oldestBlock 25673288 vs 25673290 on a reference node, run 30798564490). Pin one overlay read view in NewGasPriceOracleBackend so the head the oracle resolves and the per-block data it samples come from the same view, and build the backend returned by Fork() through the same constructor: the per-block sampling of FeeHistory and SuggestTipCap runs on the forked tx, which used to be a raw tx with no overlay (the gap noted in the #22006 review). Senders and receipts of the in-flight block are served by the overlay because the senders stage and execution run on the overlay tx before publication (forkchoice.go). Both tests verified red without the fix (window ends on the committed head), green with it. The test harness now also writes the forkchoice head marker to the overlay, which is what rpchelper.GetLatestBlockNumber resolves from in production.
Fork used to re-resolve LatestSD() on its fresh tx: if the commit window closed (PublishOverlay(nil)) between the request start and the fork, the forked backend had neither the overlay nor the committed head block, so eth_feeHistory failed with block-not-found on the header path or silently dropped the head slot on the rewards path. The backend now captures the overlay instance once at construction and Fork wraps its tx with that same instance; plain-table reads on a closed overlay are safe (memStore close is a no-op on the data). Also extend TestFeeHistory_OverlayHeadWithRewards with two overlay txs and their receipt-domain entries: a low percentile lands on the cheap tx only when the receipts' gas is actually read, so the assert pins the receipt values instead of just the window bounds.
WithOverlay and WithTemporalOverlay now resolve through LatestOverlay instead of carrying three copies of the same nil-check chain. Test harness: drop the never-read overlayTxs field, move the docstring onto the constructor that implements the behavior, restore t.Helper in the signing wrapper. No behavior change.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
db/kv/membatchwithdb/memory_mutation.go:1089
- This method-promotion claim is incorrect for the usual wrapper that embeds
kv.Txorkv.TemporalTx: Go promotes only methods in the embedded interface's static method set, which does not includeOverlayView. Such wrappers must forwardOverlayViewexplicitly, so documenting that requirement avoids silently losing the pin.
// OverlayViewCarrier is implemented by txs that are pinned overlay views.
// A tx wrapper that embeds such a tx keeps the marker through method
// promotion, where a concrete-type switch would silently lose it.
yperbasis
left a comment
There was a problem hiding this comment.
Combined review (own pass plus verified Copilot/Codex reports), categorized by severity.
High
rpc/jsonrpc/eth_system.go:528:CanonicalHashdelegates to the block reader. In rpcdaemon modeRemoteBlockReader.CanonicalHashignoresb.txand queries the live service, and the later fetch resolves the number again — a reorg in between caches sibling B's fees under sibling A's hash, and the hash-keyed entry is never invalidated. It also adds one remote call per block even on cache hits. Readkv.HeaderCanonicalthrough the pinned tx, fetch by that exact hash (or validate the fetched header's hash), and fall back to the block reader only for the frozen range. Doing so also removes the new per-block lookup on the warm-cache path and the double hash resolution on misses (rpc/gasprice/feehistory.go:394). (Confirms the unresolved Copilot thread.)
Medium
rpc/jsonrpc/eth_system.go:354and:201:eth_baseFee,eth_blobBaseFee, andeth_blockNumberkeep the non-atomicBeginTemporalRo→WithTemporalOverlayacquisition — the same race class this PR fixes for the sibling fee endpoints, so the cross-endpoint skew can still fire there. Each is a one-line migration toBeginTemporalRoWithOverlay.rpc/jsonrpc/eth_system.go:505: for a tx without a pin,NewGasPriceOracleBackendre-resolvesLatestOverlay()with no seq-stability check, soeth_fillTransactioncan pricemaxPriorityFeePerGasandmaxFeePerGason two different heads. Pin at tx acquisition (or reuse the request's captured snapshot) there too.db/kv/membatchwithdb/memory_mutation.go:1121:noOverlayViewembeds the bare interface, so it (a) dropsHasBlockFilesRoTx— every no-overlay request (always, in remote mode) loses the tx-pinned block-files view, paying a view acquisition per read and letting one request straddle a snapshot merge — and (b) promotesApply, whose callback receives the raw unpinned tx (latent: norpc/callers today). ForwardBlockFilesRoTxand overrideApplyas the sibling views do. (Confirms the other unresolved Copilot thread.)rpc/rpchelper/filters.go:1227: after 3 acquisition attempts under a moving seq, fee RPCs now fail witherrOverlayUnstable— a new client-visible error during FCU catch-up/churn. Prefer serving the last coherent capture (tx plus its captured overlay) over returning an error.
Low
rpc/rpchelper/filters.go:1214: the(pinned, raw)return contract is a misuse trap —pinned.Rollback()panics on a read view but releases the raw tx onnoOverlayView. A single handle whoseRollbackreleases the raw tx would be safer for the planned follow-up call sites.db/kv/membatchwithdb/memory_mutation.go:1087: theOverlayViewCarrierdoc overclaims — embedding the tx interface promotes only its static method set, soOverlayViewis lost unless the wrapper embeds the concrete view type or forwards it explicitly. Reword.rpc/rpchelper/filters.go:1236:LatestOverlayduplicatesOverlaySnapshot's resolution chain (viaLatestSDand its never-writtenlatestSDfallback); express it asoverlay, _ := ff.OverlaySnapshot().rpc/jsonrpc/eth_system.go:266(and the two sibling handlers):newGasOracleFromBackend(NewGasPriceOracleBackend(api.db, tx, api.BaseAPI))is byte-identical toapi.newGasOracle(tx). Relatedly, the backend'soverlayfield (:498) is derivable from the pinned tx viaViewOverlay(b.tx).rpc/gasprice/gasprice_test.go:303:mockOracleBackend.CanonicalHashreturns the head hash for every height, violating the ok=false-beyond-head contract and collapsing all cache keys; latent until a test combines the mock with aFeeHistoryCache.db/kv/membatchwithdb/memory_mutation.go:1151: the view-of-view branch innewReadViewMutis unreachable (all wrap points guard withCarriesOverlayView); drop it or assertm.overlay == nil.- Layering: only the
OverlayViewCarriermarker needs to live indb/kv/membatchwithdb;PinToOverlay's request-pinning policy could sit next toBeginTemporalRoWithOverlayinrpchelper.
|
All points addressed: H1) Fixed as suggested: CanonicalHash now reads kv.HeaderCanonical through the pinned tx and falls back to the block reader only for the frozen range Test: TestGasPriceOracle_CanonicalHashUsesPinnedView, red before the fix with a reader that resolves on the live view like the remote one. M1) All three migrated to BeginTemporalRoWithOverlay. Tests: TestBlockNumber_PublishCycleDuringTxAcquisition and TestBaseFee_PublishCycleDuringTxAcquisition, both red before the migration (head hidden by a publish/commit/unpublish cycle landing during the open); eth_blobBaseFee is the identical mechanical change L1-L7) All seven done. The first and the last converged with the noOverlayView fix into the single rpchelper.PinnedRoTx: one handle whose Rollback releases the raw tx, with the request-pinning policy living next to BeginTemporalRoWithOverlay and only the generic marker staying in membatchwithdb. The rest: marker doc reworded to state the interface-embedding caveat, LatestOverlay expressed via OverlaySnapshot, the three handlers reuse newGasOracle and the backend's overlay field is gone (derived from the pinned tx via ViewOverlay), the mock honors the ok=false-beyond-head contract, and the unreachable view-of-view branch is dropped Verification: re-ran QA - RPC Integration Tests Latest 10× on this branch (two 5-run batches, morning and evening tip conditions) — zero eth_feeHistory failures in any attempt of any job, vs ~40% of main's daily runs flaking at attempt level. Full tables with per-run links in the PR description. |
- CanonicalHash resolves on the pinned tx and falls back to the block reader only for the frozen range: the remote reader ignores the caller's tx, which could cache one sibling's fees under another sibling's hash and paid a remote call per block. The cache store also validates the fetched header's hash against the key. - A single rpchelper.PinnedRoTx serves both the overlay and the no-overlay pin: it forwards BlockFilesRoTx, hands itself to Apply callbacks, and its Rollback releases the raw tx. noOverlayView is gone and the request-pinning policy lives next to BeginTemporalRoWithOverlay, with only the generic marker staying in membatchwithdb. - BeginTemporalRoWithOverlay serves the last capture as one pinned view under sustained publish churn instead of returning a client-visible error. - eth_blockNumber, eth_baseFee, eth_blobBaseFee and eth_fillTransaction acquire through the pinned helper, closing the remaining same-file instances of the non-atomic acquisition. - Cleanups: the marker doc states the interface-embedding caveat, LatestOverlay derives from OverlaySnapshot, the gas-oracle handlers reuse newGasOracle and the backend's overlay field is derived from the pinned tx, the mock honors the CanonicalHash contract, and the unreachable view-of-view branch is dropped. Every behavioral fix is pinned by a regression test that was red before it (overlay_race_test.go).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (1)
rpc/jsonrpc/eth_system.go:528
CanonicalHashnow pins the cache key, but the subsequentHeaderByNumberandBlockByNumbercalls still resolve numeric blocks throughBaseAPIand_blockReader.CanonicalHash. In rpcdaemon mode,RemoteBlockReaderignores the transaction, so a reorg can make the fetchers process live sibling B while the range and cache key are pinned to sibling A; the hash check only prevents cache insertion and does not prevent a mixed response. Resolve the hash through this pinned method and fetch the header/block by that exact hash instead.
func (b *GasPriceOracleBackend) CanonicalHash(ctx context.Context, number uint64) (common.Hash, bool, error) {
yperbasis
left a comment
There was a problem hiding this comment.
Combined review of the current head (e2661e0), categorized by severity. High and Medium gate approval; Low are cheap to fold in; the last section is follow-up material.
High
rpc/gasprice/feehistory.go:394— the cache-hit path went from a zero-I/O number-keyed LRU get to a per-block CanonicalHash resolution. In remote rpcdaemon mode a fully-memoizedeth_feeHistory(1024, latest)now costs roughly 1024–2048 sequential gRPC round trips (a remote GetOne per block, plus the RemoteBlockReader fallback for pruned heights), and a transient error on this auxiliary lookup fails the whole request viareturn err— the old hit path had no failure mode at all. Degrade tocacheable=falseon a resolution error, and skip the resolution (or keep number-keyed entries) at or below the frozen boundary, where the number-to-hash mapping is immutable.
Medium
rpc/rpchelper/pinned_tx.go:47— the view comes fromoverlay.NewReadView(tx), so the handle's requiredFreezeInfo()promotes toMemoryMutation.FreezeInfo'spanic("not supported")whenever an overlay is published (the no-overlay pin works). Delegate tot.raw.FreezeInfo()(or useNewTemporalReadView, which forwards it) and extendTestBeginTemporalRoWithOverlay_PreservesOptionalInterfacesaccordingly. Same class:Applybypasses the temporal tx's closed-tx guard, and the wrapper hidesUnderlyingTx()/Pin()of the wrapped view.rpc/jsonrpc/eth_system.go:503— the fallback pin pairs a caller-opened tx withLatestOverlay()resolved at construction time, without the publish-seq bracket — the torn (tx, overlay) pairingBeginTemporalRoWithOverlay's retry loop exists to close. All production callers pre-pin today, so require the pin (assertCarriesOverlayView) instead of silently re-resolving. AlsoForkdiscards thepinnedbool ateth_system.go:518.rpc/jsonrpc/overlay_race_test.go:702— thebeginHookDBhooks run testifyrequireandt.Cleanupon errgroup fetcher goroutines (Fork → BeginTemporalRo → hook), violating the FailNow same-goroutine contract: a failing hook Goexits the worker,errgroup.Waitreturns nil, and the test fails at an unrelated downstream assert. Return errors from the hooks and assert on the test goroutine; that also lets the five copy-pasted publish/commit/unpublish blocks collapse into one helper.rpc/gasprice/gasprice_test.go—mockOracleBackend.CanonicalHashreturnsm.head.Hash()for every height, so all block numbers share one cache key; dormant only because everyNewOraclecall passeshistoryCache=nil. Derive a per-height hash.
Low
rpc/gasprice/feehistory.go:440— the compute path re-resolves the canonical hash inside HeaderByNumber/BlockByNumber after line 394 already did, plus a keccak per stored block for thefees.header.Hash() == blockHashguard. Fetching by the already-resolved (hash, number) pair makes the guard hold by construction.rpc/jsonrpc/eth_fill_transaction.go:53— the comment claims the pinned view keeps the whole request on one head, but the GetTransactionCount / EstimateGas sub-calls open their own unpinned txs; scope the comment or thread the pinned tx through.- The pin-once rationale is written out in full at four sites (
eth_system.go:498,eth_system.go:516,rpc/rpchelper/filters.go:1226,memory_mutation.go:1105); keep the canonical statement onPinToOverlayand reduce the rest to pointers.PinnedRoTx.Rollback's comment restates the type doc above it. rpc/jsonrpc/eth_system.go:502— theCarriesOverlayViewguard duplicates the same check insidePinToOverlay;newGasOracleFromBackendhas a single caller one line above it.rpc/jsonrpc/overlay_race_test.go:643—commitOverlayBlockomits the Execution stage-progress and canonical-TxNums markers production writes, so the harness commit only supports header-only reads; complete the markers or note the limitation before more tests build on it.
Follow-ups (fine as separate issues, not gating this PR)
Fork(eth_system.go:512) pins the parent's overlay over a freshly opened tx: a reorg of depth two or more committing mid-request can mix two chains in one response (also reachable via the exhausted-attempts fallback inrpc/rpchelper/filters.go:1212). Not a regression — pre-PR Fork had no pin at all.- The per-endpoint migration leaves ~105 handlers and ~40 WithOverlay call sites on the torn-view pattern; a single acquisition chokepoint (a BaseAPI-level begin helper) would cover all sites at once, after which WithOverlay collapses into PinToOverlay.
PendingBlockAndReceipts(eth_system.go:579) returns the livependingBlock(), contradicting the "always a pinned view" field comment — pre-existing; route through the pin or scope the comment.- A pinned request retains the torn-down SharedDomains (order 10–50MB) for its lifetime — bounded and safe by design, but worth a note in the PR description.
- The fee-history cache goes dual-regime: at or below the frozen boundary entries stay number-keyed and the hit path is zero-I/O again; above it they are hash-keyed, and a resolution error degrades the block to uncacheable instead of failing the request. The boundary reads the Snapshots stage progress through the pinned tx, which also works in remote mode where the reader/tx frozen APIs panic. Blocks are fetched by the already-resolved (hash, number) pair, so the store guard holds by construction and the per-block keccak is gone. - PinnedRoTx delegates FreezeInfo to the raw tx, routes Apply through the raw tx's guard while still handing the pinned handle to the callback, and forwards UnderlyingTx and Pin. - NewGasPriceOracleBackend requires a pinned tx (panics otherwise) instead of silently re-resolving the overlay, making the pin a constructor invariant; Fork derives the parent's overlay from the pinned tx. - Comment hygiene: the pin-once rationale lives only on PinToOverlay, the fillFeeDefaults comment is scoped to what the pin actually covers, and the duplicated CarriesOverlayView guard and single-caller helper are inlined. - Test harness: the begin hooks return errors and assert on the test goroutine, the five publish/commit/unpublish copies collapse into newCycleHookDB, the harness commit writes the Execution stage progress and canonical TxNums markers, and the mock derives a per-height canonical hash and honors the ok=false-beyond-head contract. Every behavioral fix is pinned by a regression test that was red before it (gasprice_test.go, overlay_race_test.go).
# Conflicts: # rpc/gasprice/gasprice_test.go
|
All points addressed: H1) Done as prescribed: at or below the frozen boundary entries are number-keyed and the hit path is back to zero I/O; above it the hash key stays, and any resolution error degrades the block to cacheable=false instead of failing the request. The boundary comes from the Snapshots stage progress through the pinned tx — one read per request that works in remote mode too, where every reader/tx frozen-API panics. Tests: TestFeeHistory_FrozenRangeCachesByNumberWithoutResolution and TestFeeHistory_CanonicalHashErrorDegradesToUncached, both red before the fix. M1) Done as prescribed: PinnedRoTx now delegates FreezeInfo to the raw tx, routes Apply through the raw tx's guard while still handing the pinned handle to the callback, and forwards UnderlyingTx/Pin. Test: TestBeginTemporalRoWithOverlay_PreservesOptionalInterfaces, extended and red before the fix. L1) Done as prescribed: added HeaderByHashNumber/BlockByHashNumber to the oracle backend, so the compute path fetches by the pair already resolved for the cache key — the fees.header.Hash() == blockHash guard now holds by construction and the per-block keccak is gone. |
…rigontech#23279) Fixes erigontech#23194. Same class of bug as erigontech#23193: block tags resolved on the overlay view while the data scan reads the committed view. During an FCU background-commit window, `eth_getLogs` on `latest` failed transiently and `trace_filter` silently omitted the head block. ## Changes - `eth_getLogs`: resolve user tags with `nil` filters, on the same committed view as the `latest` baseline and the log scan - `trace_filter`: same, plus `CheckBlockExecuted` on an explicit `toBlock` so a not-yet-executed block errors instead of being silently clamped away - `debug_getModifiedAccountsByHash`: add the `startNum > latestBlock` guard its ByNumber twin already has Trade-off (as accepted in erigontech#23193): `pending` resolves to the latest executed block. ## Second commit: getLogsV3 complexity SonarCloud flagged `getLogsV3` on this PR (`go:S3776`, 64 against the 60 allowed). Pure refactor, no behaviour change: the three duplicated maxResults-capped append loops become `appendErigonLogs`, the state-sync lookup becomes `borStateSyncLogs`. 78 → 44 by gocognit, Sonar issue now closed as fixed. ## Notes - erigontech#22533 carries the same `nil`-filters hunks as part of a broader view-consistency pass; whichever merges second rebases trivially. - Medium term, erigontech#22987 introduces a pinned per-request view (`BeginTemporalRoWithOverlay`); migrating these call sites to it is the agreed follow-up — this PR keeps the endpoints correct in the meantime. ## Testing New tests in `overlay_race_test.go`, reusing the overlay helper introduced by erigontech#23193 plus a new `newHeaderAheadTester` helper (canonical header committed one past execution progress). All verified red before the fix and green after: - `TestGetLogs_UsesCommittedFromTag` / `TestGetLogs_UsesCommittedToTag` - `TestTraceFilter_UsesCommittedFromTag` - `TestTraceFilter_FutureToBlockErrors` - `TestGetModifiedAccountsByHash_FutureStartBlockErrors` The refactor commit is behaviour-preserving, so the existing `TestGetLogs_*` tests are its safety net; `TestAppendErigonLogs` and `TestBorStateSyncLogs_NoEvents` / `_EventsError` pin the extracted helpers
| // KV read that works in both embedded and remote mode, unlike the block | ||
| // reader's FrozenBlocks which panics remotely. | ||
| func (b *GasPriceOracleBackend) FrozenBlocks() (uint64, error) { | ||
| return stages.GetStageProgress(b.tx, stages.Snapshots) |
yperbasis
left a comment
There was a problem hiding this comment.
Requesting changes for one blocking performance regression.
High
rpc/gasprice/feehistory.go:419: the warm cache path still performs one canonical-hash lookup per hot block. FeeHistory calls localBackend.CanonicalHash before consulting the LRU at line 428 for every block above frozenBound. In rpcdaemon mode this reaches remoteTx.GetOne / SeekExact, so each lookup is a remote stream round trip.
The dual cache regime removes this cost only for old frozen ranges. The recent latest window remains above the frozen boundary, so a fully memoized eth_feeHistory(1024, latest) can still issue up to 1,024 remote lookups on every request. This defeats the cache fast path and creates user-controlled RPC amplification.
Please batch-resolve the hot canonical range or add a reorg-aware number-to-hash cache, and cover the warm rpcdaemon path with a benchmark or regression test.
overlay_race_test.go: keep the branch's overlayAheadHarness and adopt main's insertOverlayRaceChain helper; the three tests from #23279 now build their API through the harness.
…nge scan The hash-keyed cache regime above the frozen boundary resolved one canonical hash per block before consulting the LRU, so a fully memoized eth_feeHistory(1024, latest) cost up to 1024 remote round trips in rpcdaemon mode — user-controlled amplification on what used to be a zero-I/O hit path. OracleBackend.CanonicalHash is replaced by CanonicalHashes(from, to), resolved once per request before the fetchers fan out and over the unfrozen tract only. GasPriceOracleBackend implements it as a single kv.HeaderCanonical range scan on the pinned tx: the overlay head marker stays visible (MemoryMutation.Range merges the memory and db streams) and the block reader, live in rpcdaemon mode, stays out of the cache key. The reader fallback goes away with it, since only unfrozen heights are asked for. A scan error clears the resolved slice, so those blocks degrade to uncached instead of failing the request. Test: TestFeeHistory_HotRangeResolvedInOneScan, red before the fix with eight single-height resolutions.
|
High — fixed: Test: |
eth_feeHistoryresolves its head on the committed view whileeth_blockNumberpublishes the overlay head, so during the forkchoice flush+commit window the fee window lags the published head by one block or more. Seen as intermittenteth_feeHistory/test_07.jsonfailures in the latest QA runs, e.g. 30798564490 (oldestBlock25673288 vs 25673290 on the reference node — two blocks behind while a commit backlog drained) and 30803768568 (failed on attempt 1, passed on attempt 2).Fix — each gas-oracle request reads one consistent view, pinned at acquisition:
Filters.BeginTemporalRoWithOverlay(rpc/rpchelper) opens the tx and captures the published overlay as one consistent pair: publishes carry a monotonic sequence number (Events.OverlaySnapshot), the open retries while the sequence moves, and under sustained publish churn the last capture is served as one pinned view — a slightly stale answer beats a client-visible error. It returns the tx already pinned, and the pin travels inside the tx (themembatchwithdb.OverlayViewCarriermarker, applied byrpchelper.PinToOverlay), so downstream overlay wrap points leave it alone — and the remaining call sites with the same exposure can adopt the helper as a follow-up.rpchelper.PinnedRoTxhandle: it forwards the tx's optional capabilities (BlockFilesRoTx,FreezeInfo,UnderlyingTx,Pin), routesApplythrough the raw tx's guard while handing the pinned handle to the callback, itsRollbackreleases the raw tx, and an overlay published mid-request cannot leak in through helpers re-resolving the live overlay. This also completes the tip component ofeth_gasPrice(the gap noted in the rpc: use overlay tx in GasPrice, BlobBaseFee, BaseFee to avoid flaky test on latest #22006 review).NewGasPriceOracleBackendrequires the pin carried by the caller's tx (panics on an unpinned tx) instead of re-capturing the live overlay, andFork()wraps its fresh txs with that same pin, so head resolution, per-block sampling and the parallel fetchers all read one view.OracleBackend.CanonicalHash), so a same-height sibling after a reorg misses by construction; a resolution error degrades the block to uncacheable instead of failing the request. Blocks are fetched by the already-resolved (hash, number) pair, so a stored entry matches its key by construction.CacheableBlockLimitis gone fromOracleBackend.Bounds and data agree: senders and receipts of the in-flight block are served by the overlay because the senders stage and execution run on the overlay tx before publication (
execution/execmodule/forkchoice.go), and receipt domain reads go through the overlay since #22511.Memory bound: a pinned request retains the overlay it captured — including a
SharedDomainsalready superseded by a newer publish, order 10–50 MB — for its own lifetime. The retention is bounded by the request, not by the publish rate, and the memory is released with the last pinned tx.Tests (
overlay_race_test.go,gasprice_test.go), grouped by what they pin:TestFeeHistory_SeesOverlayHead(header-only path),TestFeeHistory_OverlayHeadWithRewards(block+receipts path viaFork, asserting the receipts' gas is actually read);TestGasPriceOracle_PinnedViewIgnoresLaterOverlayPublish,TestGasPriceOracle_ForkKeepsOverlayAfterUnpublish,TestGasPriceOracle_ForkSharesCallerPinnedOverlay(caller-pinned tx),TestGasPriceOracle_NilOverlayPinIgnoresLaterPublish(nil pin),TestGasPrice_BaseFeeFromPinnedOverlay;TestFeeHistory_HeadCommittedDuringTxAcquisition,TestFeeHistory_PublishCycleDuringTxAcquisition(publish/unpublish cycle),TestFeeHistory_OverlayUnstableDuringTxAcquisition(serves the last capture as one pinned view under churn),TestBlockNumber_PublishCycleDuringTxAcquisition,TestBaseFee_PublishCycleDuringTxAcquisition,TestFillTransaction_PublishCycleDuringTxAcquisition;TestBeginTemporalRoWithOverlay_PreservesOptionalInterfaces(optional tx capabilities survive the pin, in both the overlay and the no-overlay case),TestGasPriceOracleBackend_RequiresPinnedTx(the constructor invariant);TestFeeHistory_DeadOverlayBlockNotServedFromCache,TestFeeHistory_ReorgedCommittedBlockNotServedFromCache,TestGasPriceOracle_CanonicalHashUsesPinnedView(cache key resolved on the pinned view even when the block reader resolves live, as the remote one does),TestFeeHistory_FrozenRangeCachesByNumberWithoutResolution(zero-I/O hit path below the frozen boundary),TestFeeHistory_CanonicalHashErrorDegradesToUncached(a resolution error degrades instead of failing).All fix tests were verified red before the corresponding fix and green after. The harness also writes the forkchoice head marker to the overlay — what
rpchelper.GetLatestBlockNumberresolves from in production.QA verification
QA - RPC Integration Tests Latestis being dispatched repeatedly on this branch (head5ed15a18) to confirm the intermittenteth_feeHistoryfailures are gone. Each run is verified attempt by attempt: noeth_feeHistoryfailure may appear in any attempt's logs, not just in the final outcome — the suite retries internally, so a green run can hide the flake.Baseline: the same workflow on
mainScanning the job logs of the last 16 daily runs on
main(Jul 30 – Aug 13), 7 had aneth_feeHistory/test_07.jsonfailure (diff mismatchonbaseFeePerGas/baseFeePerBlobGas, i.e. the window resolving a different head) in at least one attempt — all but one masked by the retries:test_07among them)Clean days: Aug 13, 12, 10, 9, 8, 7, 3, 1, Jul 31 — a ~40% daily flake rate.
This branch
eth_feeHistorytest_07+test_22OK in both jobs, 218/218 greentest_07+test_22OK in both jobs, 218/218 greentest_07+test_22OK in both jobs, 218/218 greentest_07+test_22OK in every attempt of both jobs; the retries weredebug_traceBlockByNumber/test_42(unrelated API, recovered on attempt 4)test_07+test_22OK in both jobs, 218/218 greenFirst batch: 5/5 runs with zero
eth_feeHistoryfailures in any attempt (under themainbaseline rate, ~7% probability by luck). The flake is bursty (mainhad a week-long clean streak with the bug still in), so the batch was repeated in the evening under different tip conditions:Second batch, evening tip conditions, head
e2661e05(review round four + freshly mergedmain):eth_feeHistorytest_07+test_22OK in both jobs, 218/218 greentest_07+test_22OK in both jobs, 218/218 greentest_07+test_22OK in every attempt of both jobs; the retry wasdebug_traceBlockByNumber/test_44+test_47(unrelated API, recovered on attempt 2)test_07+test_22OK in every attempt of both jobs; samedebug_traceBlockByNumber/test_44+test_47retry (recovered on attempt 2)test_07+test_22OK in every attempt of both jobs; the retry was 6debug_traceBlockByNumbertests (recovered on attempt 2)Result: 10/10 runs across two batches (morning and evening tip conditions, two heads) with zero
eth_feeHistoryfailures in any attempt of any job — under themainbaseline rate (~42% of daily runs flaking), ten consecutive clean runs have a ~0.4% probability of being luck. The post-merge daily scheduled runs onmainwill keep sampling for free.The round-five head
9315c32dchanges the cache regime and the handle forwarding on the same pinned acquisition path the batches verified; any regression there would surface in the daily runs.Side observation from the same logs:
debug_traceBlockByNumberflaked in 4 of the 10 runs (always recovering on an internal retry) — the same head-consistency class this PR fixes for the gas-oracle endpoints, untouched here and a natural first target for the agreed follow-up call-site migration.