rpc/jsonrpc: gate receipts and logs on the data they actually read - #23322
Conversation
f80bc45 to
3f49b2b
Compare
Three defects in the same family. The receipts gate read kvcfg.PersistReceipts, a boolean saying the receipt cache exists on disk, and concluded receipts were available for every block. Retention is a separate setting: RCacheDomain is retired on its own --prune.receipts.distance window when one is set, and alongside state history otherwise, which is the default. Consult prune.Mode.ReceiptsAmount() instead, distinguishing the same three shapes historyRetireCutoffs already distinguishes. Endpoints that never read the receipt cache were gated on it. eth_getLogs, erigon_getLogs and erigon_getLatestLogs match on LogAddrIdx and LogTopicIdx, standalone inverted indices retired at the history cutoff whatever the receipt retention; gated on receipts they answered with an empty log array instead of PrunedError once those indices were gone. erigon_getLatestLogs also re-executes through a TraceWorker, and overlay_getLogs re-executes with overridden code, for which stored receipts are unusable by construction. These four move to checkPruneHistory. Endpoints serving the receipts of one block were gated on history alone, which both rejects blocks whose body and receipts are present and misses the body boundary entirely. Add checkBlockReceiptsAvailable, composing the two, and move them onto it: reading a stored receipt needs the block body anyway, since the receipt carries no TxHash and it is derived from the transaction. The two GraphQL block-detail entry points had no gate at all and gain one, placed before the body read: after it, a pruned body reads back as nil and the endpoint answers "not found" before any gate could fire. The table now pins 30 endpoints across six prune mode shapes on an old and a recent block; 24 of those cells were red before this change. Direct tests cover the boundary block itself, the boundary named in each error, the archive short circuit, each receipt retention shape, and each leg of the composed gate.
3f49b2b to
8f24508
Compare
…dices An unfiltered eth_getLogs never reads them: applyFiltersV3 consults LogTopicIdx and LogAddrIdx only when the criteria carry topics or addresses, and otherwise falls through to a plain txNum range, so the query is served from receipts alone. Gating it on history refused requests that the receipt cache could answer. Keep the receipts gate for every call and add the history one only when the criteria force the index search. erigon_getLogs is the same shape; erigon_getLatestLogs and overlay_getLogs stay on history unconditionally because they re-execute rather than read stored receipts. The table now carries both variants. They diverge under --prune.receipts.distance=keep-all, where the unfiltered query is served and the filtered one is refused.
…the index search Three corrections to the gates this branch introduces, found by probing shapes the endpoint table did not cover. checkReceiptsAvailable refused a block outside the receipt-cache window even when state history still reached it. A missing cache entry is not fatal: ReadReceiptCacheV2 reports it as absent and GetReceipt re-executes the block, which reads ReceiptDomain and state history, both retired at the history cutoff rather than at the RCacheDomain one. The finite-window branch now falls back to the history gate, which is what the function's own docstring already described. The log gate keyed its index leg on len(crit.Topics), but applyFiltersV3 skips topic positions that are empty because those match any topic. A query written as "topics": [null] therefore searches no index, yet was refused on a node where the identical unfiltered form answered with its logs. usesLogIndex now mirrors applyFiltersV3. Serving a log means deriving its receipt from the block's transaction, exactly the reason checkBlockReceiptsAvailable exists, so getLogsV3 needs the block body as much as the single-block receipt endpoints do. Without the blocks leg, bodies pruned alongside receipts kept made eth_getLogs skip the block silently and answer with an empty array instead of PrunedError. checkLogsAvailable composes the three boundaries and replaces the duplicated gate block in eth_getLogs and erigon_getLogs, so the rationale is stated once. Tests: usesLogIndex over the criteria shapes, both legs of checkLogsAvailable, the receipt window in both directions relative to history, plus a minimal_receipts_keep_all shape and an empty-topic-position endpoint in the prune-mode table. checkTxFee gains the unit test it never had. pruneGateFires no longer resolves a receipt window of its own, which the direct tests own; a guard fails the table if a shape is added that it cannot predict. The testing.Short() guards on the gate tests are dropped since each runs in under half a second.
…sactions GetReceipts decided whether the receipt cache had answered by testing len(receiptsFromDB) > 0, which cannot tell "the cache holds nothing for this block" from "this block has no transactions". A block with no transactions therefore fell through to PrepareEnv, which reads state history, so on a node whose history is pruned the four endpoints reading a block's receipts answered with a leaked low-level error where the correct answer is an empty list: ReceiptsGen: PrepareEnv: bn=1000000, old data not available due to pruning eth_getBlockReceipts, debug_getRawReceipts, ots_getBlockDetails and ots_getBlockTransactions were affected; eth_getLogs already handled such a block. The early return sits after CheckBlockExecuted, so a block that has not been executed is still reported as such rather than answered with an empty list, and before the pre-Byzantium branch, which has no post-state root to recompute without transactions. This is independent of the availability gates and predates them: the previous gate also served these blocks whenever the receipt cache was enabled. Measured on Sepolia with --prune.mode=blocks --prune.include-receipts --prune.receipts.distance=keep-all, history pruned below 11238950: blocks 1000, 1000000, 1510087 and 2000000 go from the error above to an empty list, while blocks carrying transactions are unchanged (800000 keeps 1 receipt, 1511431 keeps 4, 5750932 keeps 99, 11501058 keeps 94). No unit test accompanies this: the rpc/jsonrpc harness stores a prune mode without retiring any file, so a test there would pass without the fix. A regression test needs a fixture with history actually retired, which execmoduletester can build with WithPruneMode, WithFcuBackgroundPrune and WaitForBlockRetirement.
There was a problem hiding this comment.
Pull request overview
Updates receipt and log RPC pruning gates to reflect block-body, receipt-cache, log-index, and state-history availability.
Changes:
- Adds composed availability gates for receipts and logs.
- Migrates affected RPC endpoints to the new boundaries.
- Adds broad endpoint and boundary test coverage, including empty blocks.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
rpc/jsonrpc/receipts/receipts_generator.go |
Handles transaction-free blocks without re-execution. |
rpc/jsonrpc/prune_gating_test.go |
Expands endpoint gating coverage. |
rpc/jsonrpc/overlay_api.go |
Changes overlay log gating. |
rpc/jsonrpc/otterscan_block_details.go |
Updates block-detail receipt gates. |
rpc/jsonrpc/otterscan_api.go |
Updates block-transaction receipt gating. |
rpc/jsonrpc/graphql_api.go |
Adds block-detail availability checks. |
rpc/jsonrpc/eth_receipts.go |
Adds log-index and block-receipt gates. |
rpc/jsonrpc/eth_api.go |
Implements receipt retention and composed gates. |
rpc/jsonrpc/erigon_receipts.go |
Migrates Erigon receipt and log endpoints. |
rpc/jsonrpc/debug_api.go |
Updates raw-receipt gating. |
rpc/jsonrpc/check_prune_gates_test.go |
Tests gate boundaries and retention shapes. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
yperbasis
left a comment
There was a problem hiding this comment.
Requesting changes for the following blockers.
Please address the four unresolved inline findings on this head:
- erigon_getLatestLogs checks history but not block transaction availability, so a shorter blocks window can produce incomplete or empty results.
- overlay_getLogs has the same missing blocks boundary and can turn a missing block into an empty result.
- eth_getTransactionReceipt checks block zero before resolving a Bor state-sync transaction to its real block.
- The receipt gate runs before the body is inspected, so the new empty-block fast path remains unreachable when receipt history is pruned.
I also found these issues:
- eth_capabilities is now inconsistent with the new gates. When PersistReceipts is enabled with the default follow-history retention, it still reports receipts and logs from blocksOldest. A focused cross-check reported oldestBlock 0 while checkBlockReceiptsAvailable rejected blocks below 10. Finite receipt windows are also ignored, and filtered logs require the history boundary.
- resolveLogsRange handles BlockHash by loading the full block before checkLogsAvailable runs. If transaction snapshots are pruned, eth_getLogs and overlay_getLogs return a block-read/not-found error instead of PrunedError. Resolve the block number from the retained header first.
- RCache coverage is not sufficient for receipt paths that bypass RCache. Pre-Byzantium receipts with post-state calculation skip the persistent cache, and Bor synthetic receipts reconstruct historical state. The migrated endpoints can therefore pass the receipt gate and fail below pruned history.
- checkPruneBlocks treats KeepPostMergeBlocksPruneMode as disabled. On merge chains, pre-merge transaction segments are intentionally absent, so these requests pass the gate. The capabilities implementation already derives this boundary from MergeHeight.
Validation on commit 866b2f9: go test ./rpc/jsonrpc/... -count=1 passed, and git diff --check was clean.
erigon_getLatestLogs and overlay_getLogs replay every transaction of the range, so they read the block body on top of the state history the replay starts from. Gated on history alone they answered with a silently incomplete result wherever the blocks window is the shorter one, since prune.FromCli accepts the two distances without ordering them. New checkBlockHistoryAvailable composes both boundaries, mirroring checkBlockReceiptsAvailable. eth_getTransactionReceipt gated on a block number the lookup had left at zero for a Bor state sync txn, whose real block only the bridge can place. It now resolves through txnLookupWithBorFallback first, like eth_getTransactionByHash already does, so the gate measures the block the receipt belongs to instead of genesis. The blockNum == 0 sentinel and the second lookup below the gate go away with it. checkBlockReceiptsAvailable refused a block with no transactions where the receipt cache and state history are both pruned, though the body is retained and its transaction count is the whole answer. It now serves such a block, which is also what makes the zero-transaction path of GetReceipts reachable in that configuration. Bor is excluded: its state sync receipt is reconstructed from state history even where the body carries nothing. The gating tests gain a block without transactions, a Bor chain with a bridge that resolves one state sync hash, and direct coverage of both new gates. Addresses the first four review findings.
…at bypass RCache eth_capabilities never consulted ReceiptsAmount(), so with --prune.include-receipts and no window of its own it advertised the blocks boundary for receipts that are retired alongside state history. It now mirrors checkReceiptsAvailable: history when the cache is absent or follows it, genesis on an explicit keep-all, and a window of its own only where that window is wider than history, since below it the read falls back to re-execution. deleteStrategy reports the retention that decides the boundary rather than always the blocks one, and logs no longer equal receipts: they take the filtered form, which adds the history boundary for the standalone log indices. checkReceiptsAvailable now consults postStateCalculated, mirroring the generator's predicate: a pre-Byzantium receipt carries a post state the cache does not store, so it is always re-executed and reaches only as far back as history. The synthetic Bor receipt is reconstructed from the state at the end of the block, so borReceiptForBlock takes the history boundary once it knows the block carries state sync events, leaving the blocks without events served. resolveLogsRange resolves a BlockHash range through HeaderNumber instead of loading the block. Only the number was needed, and the header outlives the body, so a pruned body now reaches the gate instead of reading back as a missing block. checkPruneBlocks no longer reads the chain-history-expiry sentinel as "not pruning": pre-merge bodies are never downloaded on a chain that declares a merge point, so the cutoff is that point, as eth_capabilities and blocksRetentionCutoff already derive it. Datadirs carrying the same sentinel in History are excluded, since a legacy archive one holds every body: prune.Get reads the stored mode, which EnsureNotChanged corrects in memory without rewriting. Addresses the four review findings without an inline thread.
|
All eight are addressed: The four inline findings are answered in their threads — the first two share a small checkBlockHistoryAvailable helper since both need the same pair of boundaries, the third follows your suggestion with txnLookupWithBorFallback and no new code, and the fourth is the only place I deviated from the shape proposed: the empty-block shortcut lives in the gate rather than in the ten callers.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
Suppressed comments (4)
rpc/jsonrpc/eth_system.go:190
- Equal current cutoffs also lose the log-index retention policy here. With a young node whose history window exceeds the head and a keep-all receipt cache, both values are
0, this condition is false, andLogs.DeleteStrategyis omitted even though filtered logs will later be retired with history. Tie-breaking must retain the finite history policy.
if stateOldest > logsOldest {
logsOldest, logsAmount = stateOldest, pruneMode.History
rpc/jsonrpc/eth_system.go:180
- When the head is still below every configured window, both cutoffs are
0, so this>=can replace a finite receipts/history policy with an unlimited blocks policy. For example, withHistory=Distance(100), blocks kept forever, no receipt cache, and head 20,Receipts.DeleteStrategybecomes nil even though receipts will be pruned after block 100. Resolve equal cutoffs from the configured retention policies rather than treating blocks as stricter automatically.
This issue also appears on line 189 of the same file.
if blocksOldest >= receiptsOldest {
receiptsOldest, receiptsAmount = blocksOldest, pruneMode.Blocks
rpc/jsonrpc/eth_system.go:170
- Keep-all cache retention does not make every receipt available from genesis. The new
checkReceiptsAvailableforces receipts requiring a computed post-state (for example pre-Byzantium blocks whenFrozenBlocks()==0or commitment history is enabled) through the history gate. This branch still advertisesoldestBlock=0, so routing viaeth_capabilitiescan select this node for blocks that its receipt endpoints reject. Derive this field from the same cache-bypass conditions and fork boundary as the gate.
switch amount := pruneMode.ReceiptsAmount(); {
case amount == prune.KeepAllReceiptsPruneMode:
receiptsOldest, receiptsAmount = 0, amount
case pruneMode.ReceiptsFollowHistory():
rpc/jsonrpc/eth_receipts.go:168
- A no-index log query can still require history on Bor.
getLogsV3handles the final transaction viaborStateSyncLogs, whoseGenerateBorLogscallsComputeBlockContext; this is the same state-history dependency now gated inborReceiptForBlock. With receipts kept beyond history, this early return lets an old range containing state-sync events pass the gate and fail during generation. Add the history check inborStateSyncLogsafter confirming events exist.
if !usesLogIndex(crit) {
return nil
yperbasis
left a comment
There was a problem hiding this comment.
Requesting changes for five findings on d43a562650.
-
blocksFollowChainHistoryExpirycannot useHistory == KeepPostMergeBlocksPruneModeto identify a legacy archive. The supported--prune.mode=archive --prune.distance.blocks=keep-post-mergeconfiguration produces the same persisted pair, while snapshot download treats it as chain-history expiry and omits pre-merge transaction segments. The RPC gate therefore passes requests for missing bodies and can return empty/not-found instead ofPrunedError. The legacy mode needs to be normalized in storage or distinguished through another source of truth. -
Unfiltered Bor log queries still need state history.
checkLogsAvailableadds the history leg only whenusesLogIndexis true, butgetLogsV3handles Bor's final synthetic transaction throughGenerateBorLogs, which reconstructs end-of-block state. With receipts kept beyond history, an unfiltered query passes the gate and then fails when it reaches a block carrying state-sync events. -
eth_capabilitiesdoes not include the pre-Byzantium history requirement enforced bypostStateCalculated. With blocks and receipts kept, finite state history, and commitment history enabled, it advertises receipt availability from block zero whilecheckReceiptsAvailablerejects transaction-bearing pre-Byzantium blocks. A routing client can therefore select a node that refuses the advertised range. -
The capabilities calculation selects a retention policy by comparing only current
PruneToresults. When multiple configured windows have not started pruning, their oldest values are all zero, and the>=branch can replace a finite history or receipt window with keep-all (or with a wider block window).deleteStrategyis then omitted or reports the wrong retention even though the category will be pruned later. Resolve equal cutoffs using the underlying policies, not their current oldest block alone. -
blockHasNoReceiptsrejects every Bor block before checking whether state-sync events exist. A retained old block with no transactions and no state-sync events is answerable as an empty receipt list, but the gate returnsPrunedError. This also blocks GraphQL and Otterscan paths that request only regular receipts. Inspect the events or separate the gate for callers that include synthetic Bor receipts.
Local validation: go test ./rpc/jsonrpc/... -count=1, the focused snapshot-retention tests, and git diff --check all passed.
…pabilities Review follow-ups on the prune gates. blocksFollowChainHistoryExpiry read chain-history expiry off the persisted prune mode, which cannot resolve it: an archive datadir written before keep-all became the Blocks default and --prune.mode=archive with --prune.distance.blocks=keep-post-merge store the same sentinel pair, and the downloader reads that pair as expiry. Resolve it from MinimumBlockAvailable instead, once per process. borStateSyncLogs reconstructs logs from the state at the end of the block, so an unfiltered query reaching that txn needs history even though it searches no log index. This is what borReceiptForBlock already did on the receipts path. eth_capabilities did not mirror postStateCalculated: below Byzantium the receipt carries a computed post state, so those blocks follow history rather than the receipt cache. The capabilities retention was picked by comparing current PruneTo results, which are all zero before a window starts pruning, so a tie could drop a finite window in favour of keep-all. Resolve equal cutoffs on the policies themselves. blockHasNoReceipts excluded every Bor block; only a block carrying state sync events has a receipt to reconstruct, so an old empty one is answerable.
| r.frozenBlocksValue = reply.FrozenBlocks | ||
| r.frozenBlocksFetchedAt = time.Now() | ||
| } | ||
| return r.frozenBlocksValue |
There was a problem hiding this comment.
FrozenBlocks() hands out 0 on a failed or timed-out fetch, and 0 is a load-bearing sentinel.
fetchFrozenBlocks returns r.frozenBlocksValue on the error path, which is 0 until the first success. The one consumer this RPC exists for reads that value as a boolean: receipts.PostStateCalculated returns commitmentHistoryEnabled || blockReader.FrozenBlocks() == 0.
So one failed round trip (or one 5s deadline) on a remote rpcdaemon flips every pre-Byzantium block:
Generator.GetReceiptssetscalculatePostState = true, skipsReadReceiptsCacheV2and re-executes with the per-tx post-state path;checkReceiptsAvailabletakes thecomputedbranch and falls tocheckPruneHistory, soeth_getBlockReceiptsanswersPrunedErrorfor a block whose receipts are on disk;eth_capabilitiesmovesreceipts.oldestBlockto the Byzantium height for the same reason.
The doc comment above states exactly this hazard ("Zero is not [conservative]") but the code still returns it. Either surface "never observed" to the caller distinctly, or return a value the predicate reads as "snapshots exist" until something has actually been observed.
There was a problem hiding this comment.
Fixed in #23812: FrozenBlocksObserved reports the count together with whether the backend ever answered, and the sentinel readers use it — receipts.PostStateCalculated and the eth_simulateV1 commitment path, which are the two a remote reader can reach. An unobserved zero therefore no longer stands for "no snapshots". Caching a failed attempt (your other note) is only safe with that distinction in place, so the two land together.
| // receipt paths read it as "no snapshots" and re-execute, so a failed fetch is not | ||
| // cached, and it reaches only the callers that have no observation to wait for. | ||
| func (r *RemoteBlockReader) FrozenBlocks() uint64 { | ||
| deadline := time.Now().Add(r.frozenBlocksTimeout) |
There was a problem hiding this comment.
A slow backend costs every caller a full 5s, with the request's read transaction open.
The refresh runs inline in the calling goroutine, and a failed fetch never sets frozenBlocksFetchedAt. Against a backend that is reachable but unresponsive there is therefore no observed fast path at all: line 168 can never fire, so each caller either runs its own 5s fetch or waits out one and then runs its own.
FrozenBlocks() is reached from checkReceiptsAvailable -> postStateCalculated -> receipts.PostStateCalculated, i.e. from an RPC handler that already holds a BeginTemporalRo transaction. One unresponsive backend then parks the whole handler pool for 5s a request and pins one MDBX read transaction per parked handler — the stall TestRemoteBlockReaderFrozenBlocksServesCacheWhileRefreshing protects against, but only once a value has been observed, which is exactly what never happens here.
Refreshing in a background goroutine (and serving the last value, or a distinct "unknown") would keep the handler off the wire entirely.
There was a problem hiding this comment.
Fixed in #23812: the refresh runs on a goroutine of its own and a caller that already has a value returns without waiting for it. A failed attempt is stamped like a successful one, so the fast path exists against a backend that is down: one attempt per TTL instead of one per caller. Pinned by TestRemoteBlockReaderFrozenBlocksSuppressesRepeatFetchesAfterFailure and ...ServesCacheWhileRefreshing, which now asserts the stale caller answers without waiting.
| return false, nil, err | ||
| } | ||
| } | ||
| return true, chainConfig.MergeHeight, nil |
There was a problem hiding this comment.
The expiry boundary is MergeHeight regardless of what the datadir actually holds, so eth_capabilities can advertise blocks that are gone.
holdsPreMergeBlockData already reads MinimumBlockAvailable() (line 607) but the verdict is thrown away — only the boolean survives, and the boundary returned here is always chainConfig.MergeHeight.
EnsureNotChanged explicitly allows Distance <-> KeepPostMergeBlocksPruneMode in both directions, so a datadir that ran with --prune.distance.blocks=N and is later opened with the expiry sentinel has its oldest block above MergeHeight. In that shape:
checkPruneBlocksreturnsnilfor everyblock >= MergeHeight, including the pruned range[MergeHeight, oldest);CapabilitiessetsblocksOldest = *expiryFrom = MergeHeight, soblocks.oldestBlock/tx.oldestBlockadvertise a floor with holes above it.
That is the same "holes below the advertised oldestBlock" class the PR sets out to remove. Since the probe has oldest in hand, max(*MergeHeight, oldest) would close it.
| } | ||
| // Zero is a snapshot set starting at genesis, one a database holding every block | ||
| // after it; anything higher starts mid-chain, however far below the merge point. | ||
| if oldest > 1 { |
There was a problem hiding this comment.
oldest > 1 is read as proof of expiry without comparing it to mergeHeight.
A datadir whose oldest available block sits below the merge point (segments that start mid-chain, or a datadir previously distance-pruned to a point below MergeHeight on a chain with a low merge height like Chiado) is classified as chain-history expiry. checkPruneBlocks then refuses every block in [oldest, mergeHeight) with PrunedError although the bodies and transactions are on disk, and eth_getBlockByNumber / eth_getTransactionByHash / the new checkBlockReceiptsAvailable gates all inherit that refusal.
The comment says "anything higher starts mid-chain, however far below the merge point" — but starting mid-chain and not holding pre-merge data are different facts, and only the second one justifies the expiry verdict. Comparing oldest against mergeHeight (and only short-circuiting when oldest >= mergeHeight) would keep the cheap path without the false refusal.
| defer func() { api.finishPreMergeProbe(probe, holds, err) }() | ||
|
|
||
| holds, decided, err = api.probePreMergeBlockData(ctx, tx, mergeHeight) | ||
| if err == nil && decided { |
There was a problem hiding this comment.
An undecided probe is not cached, so it re-runs on every gated RPC.
_preMergeData is only stored when decided. probePreMergeBlockData returns decided == false whenever the block data it needs is itself unreadable (hasEarlyTransaction with last == nil and no sampled body, or earlyTxnUnread from the search). On such a datadir every single call into checkPruneBlocks — which now sits on eth_getBlockByNumber, eth_getTransactionByHash, all the eth_getUncle*, and the new checkBlockReceiptsAvailable / checkLogsAvailable gates — reruns the whole probe: MinimumBlockAvailable + ~log2(mergeHeight) CanonicalBodyForStorage reads + up to earlyTxnSearchBudget (256) more in searchUserTxnBlock.
On a remote rpcdaemon each of those is a gRPC round trip, and joinPreMergeProbe serialises them, so concurrent requests queue behind one another instead of fanning out. A negative-cache entry with a short TTL for the undecided verdict would bound this; as written the "open question is not remembered" rule makes the worst datadir the most expensive one.
| // availability gates and is served while a refresh runs. Zero is not: the pre-Byzantium | ||
| // receipt paths read it as "no snapshots" and re-execute, so a failed fetch is not | ||
| // cached, and it reaches only the callers that have no observation to wait for. | ||
| func (r *RemoteBlockReader) FrozenBlocks() uint64 { |
There was a problem hiding this comment.
Reuse: this is the second hand-rolled "TTL cache + single-flight" in the same PR.
RemoteBlockReader.FrozenBlocks (here, lines 162-229) and BaseAPI.holdsPreMergeBlockData / joinPreMergeProbe / runPreMergeProbe / finishPreMergeProbe (rpc/jsonrpc/eth_api.go 542-590) implement the same mechanism — cache a value for a TTL, dedupe concurrent refreshes, publish the result to waiters, do not cache a failure — with about 120 lines between them and different rules for what a waiter gets (FrozenBlocks gives waiters the stale value and a one-retry budget; the probe gives waiters the error and retries unbounded).
golang.org/x/sync/singleflight is already in the module graph and covers the dedupe half exactly; a small shared cachedValue[T] helper would cover the rest and leave one set of rules to reason about instead of two.
There was a problem hiding this comment.
Fixed in #23812: both sites now hold a common/concurrent.CachedValue[T] — one TTL, one dedup, one rule for what a failed pass leaves behind. Each keeps its own waiting policy, which is the part that legitimately differs.
singleflight was tried and dropped. DoChan cannot join the pass in flight without possibly starting one of its own, and it runs the producer on a goroutine the caller does not own; the pre-merge probe reads through the caller kv.Tx, so a caller released by ctx.Done() rolls that transaction back under a live reader. Produce runs the pass on the caller goroutine instead, and Go is the background form for FrozenBlocks, which borrows nothing. DoChan also turns a producer panic into go panic(e), which ends the process rather than the request.
|
@AskAlexSharov I have saved all thirteen into the follow-up list |
Reconcile the pin with the read-view work that landed in #22533 and #23322. - db/kv/membatchwithdb, rpc/rpchelper: drop #22533's temporary IsOverlayReadView marker in favour of OverlayViewCarrier, so one marker system serves both contracts. WithOverlay and WithTemporalOverlay now key on CarriesOverlayView and keep #22533's NewTemporalReadView choice. - rpc/jsonrpc/eth_system.go: main's hexutil.U256 signatures with this branch's pinned acquisition on the four gas-oracle endpoints; #23322's receipt and log gating is unchanged. - rpc/jsonrpc/overlay_race_test.go: newOverlayAheadHarness becomes the single builder and main's newOverlayAheadTestAPI helpers wrap it; newPublishedOverlayTestBase also returns the overlay read tx, which the receipt-domain writes need. No test is dropped from either side. - rpc/jsonrpc/trace_view_consistency_test.go: the committed-read assertion moves to the carrier.
…#23322 (erigontech#23690) Follow-up to erigontech#23322, which merged as approved with thirteen inline review notes deferred. This closes the seven that are self-contained; the other six are grouped at the end. | Note | Site | Change | | --- | --- | --- | | [r3885706832](erigontech#23322 (comment)) | `erigon_receipts.go` | `erigon_getLogs` no longer answers `[]` for an unknown or non-canonical block hash | | [r3885706060](erigontech#23322 (comment)) | `eth_receipts.go` | `resolveLogsRange` returns the `PrunedError` it computes | | [r3885705986](erigontech#23322 (comment)) | `eth_api.go`, `eth_system.go` | the receipts gate and `eth_capabilities` follow history for a sentinel retention | | [r3885706103](erigontech#23322 (comment)) | `receipts_generator.go` | a transaction-free block is cached and answered before the exec semaphore | | [r3885706918](erigontech#23322 (comment)) | `eth_api.go` | the Byzantium check is read once, from `receipts.PostStateCalculated` | | [r3885706948](erigontech#23322 (comment)) | `block_reader_test.go` | the wall-clock assertion is replaced | | [r3885706992](erigontech#23322 (comment)) | `block_reader.go`, `eth_api.go` | three comments trimmed to the invariant they carry | Three of them are worth a line: **By-hash logs.** `resolveLogsRange`'s hash branch is extracted into `resolveLogsBlockHash` and `erigon_getLogs` goes through it, so all three by-hash endpoints answer alike. The same function no longer discards the refusal it derives; error class and message are unchanged. **Sentinel retention.** `Distance.Enabled()` is false for three values and the switch special-cased two, so `KeepPostMergeBlocksPruneMode` produced no gate while `historyRetireCutoffs` retires the RCache with history for it. Both switches now ask one predicate, which also drops the ordering dependency. It is an unexported helper rather than a second exported method on `prune.Mode`, since the existing narrow `ReceiptsFollowHistory()` still drives RCache download policy — see below. **Exec semaphore.** It is now taken where execution starts, immediately before `PrepareEnv`, so neither a transaction-free block nor the persisted-receipts fast path queues behind real re-executions. The per-block mutex still covers the whole function. ## Tests Five new, one changed. `TestErigonGetLogsByBlockHashRequiresACanonicalBlock`; a `resolveLogsRange` leg on `TestLogsByBlockHashNamesThePruneBoundary`; a `cache_sentinel_retention` row on `TestReceiptsGateFollowsRetention` plus `TestCapabilitiesFollowHistoryForASentinelRetention`; `TestGetReceiptsCachesAnEmptyBlock` and `TestGetReceiptsAnswersAnEmptyBlockWithoutAnExecSlot` (semaphore full and context already cancelled, so any path reaching the semaphore reports the cancellation). `make lint` and `make test-short` are clean. ## One deviation The flaky-assert note suggested `< 2*timeout` or a channel signal; this asserts `client.calls.Load() <= 2` instead — each caller reaches the backend at most once, and every fetch it runs is bounded by that caller's own deadline. Deterministic, but it pins the timing property less tightly; `require.Less(t, time.Since(started), 2*timeout)` can go back alongside it if preferred. ## Not in this PR Each is a behaviour change wanting its own test: - `erigon_getLatestLogs` is the third by-hash log path and still hand-rolls `HeaderByHash`. - `Mode.ReceiptsFollowHistory()` (narrow) still drives RCache download policy in `db/snapshotsync`, so for the sentinel retention download and retirement disagree. - `Generator.GetReceipt` (singular) takes no exec slot at all, though it executes.
`eth_feeHistory` resolved its head on the committed view while `eth_blockNumber` publishes the overlay head, so during the forkchoice flush+commit window the fee window lagged the published head by one block or more. It showed up as intermittent `eth_feeHistory/test_07.json` failures in the QA runs. ## Design Each gas-oracle request reads one view, resolved once at acquisition. - `Filters.BeginTemporalRoWithOverlay` opens the tx and captures the published overlay as one pair: publishes carry a monotonic sequence number, and the tx is reopened while the sequence moves around the open. A capture that never stabilizes ends on an explicit no-overlay pin — coherent by construction — rather than on a pair the helper could not match. - The pin travels inside the tx (`membatchwithdb.OverlayViewCarrier`, applied by `rpchelper.PinToOverlay`), so downstream wrap points leave it alone, both when an overlay is pinned and when the absence of one is. `rpchelper.PinnedRoTx` serves both cases and forwards the tx's optional capabilities (`BlockFilesRoTx`, `FreezeInfo`, `UnderlyingTx`, `Pin`). - `NewGasPriceOracleBackend` requires an already-pinned tx, and `Fork` reuses that pin, so head resolution, per-block sampling and the parallel fetchers read one view. The parent-snapshot identity `Fork` validates against is resolved by `PrepareFork` on the request goroutine right before the fan-out, so a request served from the tip cache performs no canonical scan. - Migrated endpoints: `eth_feeHistory`, `eth_gasPrice`, `eth_maxPriorityFeePerGas`, `eth_baseFee`, `eth_blobBaseFee`, `eth_blockNumber`, `eth_fillTransaction`. The remaining call sites with the same exposure are tracked by erigontech#23416. ## Cache strategy The fee-history cache key is dual-regime. At or below the frozen boundary — the lowest canonical marker still in the db, minus one, read once per request through the pinned tx — entries are number-keyed: the number-to-hash mapping is immutable there, so a hit requires no per-block canonical-hash lookup. Above it entries are keyed by the block hash, resolved by one `CanonicalHashes(from, to)` range scan per request instead of a per-height lookup, which would cost a remote round trip each in rpcdaemon mode. A same-height sibling after a reorg therefore misses by construction, and a resolution error degrades the block to uncacheable instead of failing the request. Blocks are fetched by the already-resolved (hash, number) pair. `pending` is never memoized: it is served from the live builder cache and rebuilt continuously. That boundary is a proxy, and a conservative one: markers are pruned at a threshold that trails the retired range, so it sits below the real frozen bound — at zero before the chain has enough retired blocks — and the number-keyed regime engages less often than it could. A fast-follow reads the boundary from `blockReader.FrozenBlocks()` instead, which also removes this method's `Range` per request. ## Data and bounds Senders and receipts of the in-flight block are served by the overlay: the senders stage and execution run on the overlay tx before publication, and receipt domain reads go through the overlay since erigontech#22511. A pinned request retains the overlay it captured — including a `SharedDomains` already 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. ## Relationship to erigontech#22533 This PR is based on erigontech#22533 and erigontech#23322, and replaces erigontech#22533's temporary `IsOverlayReadView` marker with `OverlayViewCarrier`, so one marker system serves both contracts. erigontech#22533's committed-view helpers and call sites are untouched, and `WithTemporalOverlay` keeps `NewTemporalReadView`. GraphQL stays committed: its resolvers open several transactions per request, so having the primitive does not by itself make that path atomic. ## Tests `overlay_race_test.go`, `gasprice_test.go`, `carries_overlay_view_test.go`. They pin overlay head resolution on both the header-only and the block+receipts path; one pin per request across later publishes, unpublishes and forks; atomic acquisition, including publish/unpublish cycles during the open and the no-overlay pin under sustained churn; the pinned handle's capabilities and the constructor invariant; and cache identity — dead overlay blocks, reorged siblings, windows straddling the frozen boundary, one scan per request, and the degrade-to-uncached path. Every fix test was verified red before its fix and green after. ## QA verification `QA - RPC Integration Tests Latest` was dispatched manually and sequentially, and every run read attempt by attempt: the suite retries internally, so a green run can hide the flake. At 84ce71f, 10 runs: 20 job executions, one internal attempt each — no internal retry occurred at all — 386/386 green, `eth_feeHistory/test_07` and `test_22` passing in every one. The baseline is `main` itself, where 7 of the last 16 daily runs failed `test_07` in at least one attempt — a ~40% rate. Earlier-head runs and two unrelated flakes are recorded in a comment below. The commits after it are two `main` merges and one test-only change: a test tx that dropped the block-files view capability the merged `main` now asserts on.
…23759) Follow-up to erigontech#23322. erigontech#23690 closed seven of its deferred review notes; this closes the two P3 ones it left, one per commit. | Note | Site | Change | | --- | --- | --- | | [r3880873445](erigontech#23322 (comment)) | `eth_api.go` | a log query no longer inherits the post-state requirement of a full receipt | | [r3880873497](erigontech#23322 (comment)) | `prune_gating_test.go` | the fixtures persist the receipts their retention promises | ## Logs `checkLogsAvailable` reused the full-receipt gate, which refuses a pre-Byzantium block below the history cutoff: a full receipt carries a post state the cache does not store and only a re-execution can recompute. A log query never reads that field — `getLogsV3` asks `GetReceipt` for a receipt *without* a post state, which is exactly why the generator serves the cache. So with receipts kept beyond history, an unfiltered pre-Byzantium query was refused although every byte it reads was retained. `checkReceiptsAvailable` keeps the post-state leg; the rest moves unchanged into `checkReceiptSourceAvailable`. `checkLogsAvailable` composes the blocks leg with that one and keeps the history leg behind `usesLogIndex`, since an indexed filter still searches `LogAddrIdx`/`LogTopicIdx`. Filtered queries are unaffected, and `overlay_getLogs` gates on `checkBlockHistoryAvailable` and is untouched. `eth_capabilities` is unchanged on purpose: `caps.Logs` is already `stricterRetention(receipts, history)`, so the post-state clamp was a no-op for it. `TestCapabilitiesAgreeWithTheLogsGatePreByzantium` pins that the advertised boundary does not move — the gate now reaches further back than advertised, never short of it. ## Fixtures They force-wrote `PersistReceipts` after `InsertChain` and never enabled `RCacheDomain`, so a disabled domain dropped everything execution wrote: the keep-all cells passed by re-executing against unpruned history. The domain is now enabled before the chain runs, and `requirePersistedReceipts` asserts the receipts really are on disk. ## Tests Both commits are TDD; the fixture one first failed with `"[]" should have 1 item(s), but has 0`. New: `TestLogsGateSkipsThePostStateLegPreByzantium` (one block, three answers), `TestCapabilitiesAgreeWithTheLogsGatePreByzantium`, `TestReceiptEndpointsCloseWhenTheCacheIsNotServed`. `go test -count=1 ./rpc/jsonrpc/...` green, `-race` green on the gate tests, `make lint` clean. ## Not in this PR The fixture note also asked for an endpoint test with history *physically* unavailable. A negative control confirms the gap is real: the "endpoint answers" leg passes even with zero persisted receipts. Real pruning (`WithStepSize` + `WithPruneMode`) is the right route, but it reverses this fixture's premise of inserting without physical pruning, so it wants its own PR.
…e data they read (erigontech#23760) Follow-up to erigontech#23322, same defect class as erigontech#21965: an endpoint that gates on state history refuses data that was never pruned away. - `debug_getRawBlock`, `debug_getRawTransaction` and `erigon_getBlockByTimestamp` read the body and nothing else, so they now gate on `Mode.Blocks` instead of `Mode.History`. - `eth_feeHistory` had no gate. Headers carry the base-fee and gas-used series, so only the reward-percentile path is gated, on the oldest block of the resolved range, via a new `OracleBackend.CheckBlockReceiptsAvailable`. - The endpoints that replay a block read its transactions as well as the state history before it, and gated on history alone: `trace_block`, `trace_transaction`, `trace_filter`, `trace_replayTransaction`, `trace_replayBlockTransactions`, `debug_traceBlockByNumber`, `debug_traceBlockByHash`, `debug_traceTransaction`, the Otterscan searches and the Otterscan tracer helper. They now gate on both boundaries. The `*_call` endpoints execute against the state a block leaves behind and stay on history. In the named presets history is always the stricter boundary, which is why the missing blocks leg stayed invisible. It shows up with `--prune.mode=archive --prune.distance.blocks=N`. Tests: the prune-gating table gains rows for the header, block-data, fee-history, trace and Otterscan-search endpoints, plus an `archive_blocks_window` mode row — without a mode where blocks is stricter than history, the blocks leg cannot be observed.
…#23322 (erigontech#23812) Follow-up to erigontech#23322. erigontech#23690 closed seven of its deferred review notes and erigontech#23759 two more; this closes the three that are about `RemoteBlockReader.FrozenBlocks` and the mechanism behind it. | Note | Site | Change | | --- | --- | --- | | [r3885705116](erigontech#23322 (comment)) | `block_reader.go` | a slow backend no longer costs every caller a full timeout | | [r3885704185](erigontech#23322 (comment)) | `block_reader.go` | the zero handed out before the backend answers is no longer read as "no snapshots" | | [r3885706881](erigontech#23322 (comment)) | `block_reader.go`, `eth_api.go` | one shared TTL-cache instead of two hand-rolled ones | ## Change - The refresh runs on a goroutine of its own; a caller that has a value to serve returns without waiting for the next one. A failed attempt is stamped like a successful one, so an unreachable backend costs one attempt per TTL instead of one per caller and per request. - `FrozenBlocksObserved() (uint64, bool)` reports the count together with whether the backend ever answered. `receipts.PostStateCalculated` and the `eth_simulateV1` commitment path both read it; those are the two sentinel readers a remote reader can reach. - `common/concurrent.CachedValue[T]` holds what the getter and `holdsPreMergeBlockData` each kept by hand: one TTL, one dedup, one rule for what a failed pass leaves behind. Each site keeps its own waiting policy — `Produce` runs on the caller's goroutine, `Go` refreshes behind it. ## For review **`singleflight` was tried and dropped.** `DoChan` cannot join the pass in flight without possibly starting one of its own, and it runs the producer on a goroutine the caller does not own. That is unsafe for the pre-merge probe, which reads through the caller's `kv.Tx`: a caller released by `ctx.Done()` rolls that transaction back under a live reader. `Produce` runs the pass on the caller's goroutine instead. `DoChan` also turns a producer panic into `go panic(e)`, which ends the process rather than the request. **An unobserved count now reads as "snapshots exist".** For a pre-Byzantium block on a remote rpcdaemon whose backend has not answered yet, `PostStateCalculated` returns false, so the stored receipt is served — possibly without its `root` field — where before the block was re-executed and the gate could answer `PrunedError` for receipts that are on disk. That is the direction the note asks for, and the window is one TTL after a failed round trip. **Panics.** `Produce` publishes the failed pass and re-panics, so the RPC server logs it as before; `Go` contains and logs it with the stack, since its goroutine has no caller to recover it. ## Tests `common/concurrent/cached_value_test.go` (11) pins the contract, including that a caller running the pass finishes it before returning while one that only waits is released by its own context. `TestRemoteBlockReaderFrozenBlocks*` cover the getter, `rpc/jsonrpc/receipts/post_state_calculated_test.go` the sentinel. The `eth_simulateV1` leg has no dedicated test — the branch sits inside the commitment path. `make lint` clean; `./common/concurrent/...`, `./db/snapshotsync/freezeblocks/...`, `./rpc/jsonrpc/...`, `./execution/blockreplay/...`, `./cmd/rpcdaemon/...` green, with `-race` on the concurrency tests.
Defects in the same family — how receipt- and log-serving endpoints decide availability.
1. The receipts gate consulted the wrong setting. It read
kvcfg.PersistReceipts, a boolean saying the receipt cache exists on disk, and concluded receipts were available for every block. Retention is a separate setting:RCacheDomainis retired on its own--prune.receipts.distancewindow when one is set, and alongside state history otherwise, which is the default. It now consultsprune.Mode.ReceiptsAmount(), distinguishing the same three shapeshistoryRetireCutoffsalready distinguishes. Where the cache stops covering a block, availability falls back to state history, because a missing cache entry is not fatal:ReadReceiptCacheV2reports it as absent andGetReceiptre-executes the block fromReceiptDomainplus state history, retired at the history cutoff rather than the RCacheDomain one. So a--prune.receipts.distancenarrower than the history window costs the caller nothing.2. Four endpoints were gated on receipts alone, though matching logs can need more.
eth_getLogsanderigon_getLogssearchLogAddrIdxandLogTopicIdxwhen the query filters by address or topic; those are standalone inverted indices retired at the history cutoff whatever the receipt retention is, so gated on receipts they answered with an empty log array instead ofPrunedError. They now take the history gate on top, but only when the filter actually reaches the indices:usesLogIndexmirrorsapplyFiltersV3, which skips topic positions that are empty because those match any topic — so"topics": [null]searches nothing and stays on the receipts path, exactly like the unfiltered form.erigon_getLatestLogsre-executes through aTraceWorkerandoverlay_getLogsre-executes with overridden code, so both take the history gate unconditionally.3. Ten endpoints serving the receipts of one block were gated on history alone, which both rejects blocks whose body and receipts are present and never checks the body at all. New
checkBlockReceiptsAvailablecomposes the two boundaries: reading a stored receipt needs the block body anyway, since the receipt carries noTxHashand it is derived from the transaction. The two GraphQL block-detail entry points had no gate; theirs is placed before the body read, because after it a pruned body reads back as nil and the endpoint answers "not found" before any gate could fire. The GraphQL and Otterscan block-detail paths also select one overlay view per request and thread it through resolution, gate, block and receipt reads, so a background commit cannot gate one generation while another answers.4. The log path needs the block body for the same reason.
getLogsV3reads the transaction throughTxnByIdxInBlockfor every txNum it does not find in the in-memory cache, becauseGetReceiptderives the receipt from it. With bodies pruned and receipts kept —minimalwith--prune.receipts.distance=keep-all— the lookup came back empty and the loop skipped the block silently, answering with an empty array. NewcheckLogsAvailablecomposes the three boundaries and replaces the duplicated gate block in the twogetLogs, so the rationale is stated once.5. A block with no transactions could not have its receipts read at all.
GetReceiptsdecided whether the cache had answered by testinglen(receiptsFromDB) > 0, which cannot tell "the cache holds nothing for this block" from "this block has no transactions", so an empty block fell through toPrepareEnvand its state-history read. Where history is pruned,eth_getBlockReceipts,debug_getRawReceipts,ots_getBlockDetailsandots_getBlockTransactionsanswered with a leakedReceiptsGen: PrepareEnv: ... old data not availableinstead of an empty list. The fix belongs in the generator rather than the gate: the gate decides availability from retention, and a block it lets through must not leak an execution error. Availability itself is a separate question, and the gate makes no exception for these blocks — a transaction-free block below the cutoff is refused like any other, and the caller getsPrunedErrorrather than an empty list that cannot be told apart from "no receipts". So this fix covers the blocks the gate serves: receipts retained, archive, or above the cutoff. The two boundaries are the whole gate, sooldestBlockineth_capabilitiesis the exact boundary the endpoints honour rather than a floor with holes below it.6. The blocks gate read the chain-history-expiry sentinel as "nothing is pruned".
KeepPostMergeBlocksPruneModeis a policy, not a window: on a chain declaring a merge point, pre-merge transaction segments are never downloaded, socheckPruneBlocksnow refuses belowMergeHeight, andeth_capabilitiesresolves itsblocksboundary through the same function, so the two cannot diverge. A legacy archive datadir persists the same blocks sentinel, so the datadir decides rather than the stored retention — and neither a pre-merge body nor the oldest available block can: expiry keeps pre-merge headers and bodies, and the transaction segment spanning the merge point reaches below it. The gate reads a pre-merge transaction, sampled by halving the range so the candidates stay clear of the transaction segment spanning the merge point; where the chain carries no pre-merge transaction at all, the cumulative txnum position of the last pre-merge body says so and nothing is left to be missing. The verdict is availability rather than policy, so it is cached for a short TTL in both directions instead of being settled once, and availability widening while segments arrive reopens the gate within that window. The remote rpcdaemon's block reader answersFrozenBlocksthrough a new ethbackend RPC instead of panicking, so the receipt gates,eth_capabilitiesand the receipt generator keep consulting the real value everywhere.7.
erigon_getLogsByHashconsulted its receipt cache before any gate. A receipt set cached while its block was inside the retention window stayed servable after the boundary moved past it — holes below the advertisedoldestBlock, the defect class this PR removes. The gate now runs before the cache, so a hit is gated like a miss, at the cost of one read-only tx on cache hits.This covers the audit asked for in point 2 of #22260 for
erigon_receipts.goandotterscan_block_details.go: migrated to the correct boundary rather than documented as genuinely needing history.Coverage
prune_gating_test.gopins 34 endpoints across the nine prune mode shapes on an old and a recent block: 612 cells.check_prune_gates_test.gocovers the gates directly — the boundary block itself, the boundary named in each error, the archive short circuit, each receipt retention shape including a window wider and a window narrower than history, each leg of both composed gates, andusesLogIndexover the criteria shapes.checkTxFeegains the unit test it never had, so everycheck*in the package now has one. It also pins the datadir shapes behind the blocks sentinel — aligned segments, bodies without transactions, a chain with no pre-merge user transaction, a sampled block without transactions, blocks arriving later, and a stored history retention that does not change the verdict. The remote block reader's frozen-block refresh is covered directly: a failed fetch is retried instead of being served as fresh, and a slow one delays only the goroutine that fetches.Verified on a live node
Sepolia at tip with
--prune.mode=minimal --prune.include-receipts— receipts on disk, block bodies pruned. Head 11500055, both boundaries at 11400054, old block probed 5750027. 35 checks, all passing.erigon_getHeaderByNumber,debug_getRawHeadereth_getBlockByNumber,eth_getBlockTransactionCountByNumber,eth_getUncleCountByBlockNumber,debug_getRawBlockminimaleth_getBlockReceipts,debug_getRawReceipts,erigon_getBlockReceiptsByBlockHash,erigon_getLogsByHash,ots_getBlockDetails,ots_getBlockTransactionsblocks are availableeth_getLogs,erigon_getLogshistory is availableeth_getBalance,trace_block,debug_traceBlockByNumber,ots_hasCodeThis table was measured before the log gate took the blocks leg described in point 4. On the current revision its two log rows refuse on the blocks boundary rather than the history one, because
minimalprunes bodies and that leg is checked first; the rows above and below are unaffected. The blocks leg is covered end to end by the unit tests, not by a live probe.Every recent-block probe is served. The two middle rows name different boundaries for the same block, which is what this configuration shows: before the change both said
history is available.Note that in
minimalthe receipt endpoints refuse either way, so only the named boundary changes. The behavioural flip — refused becoming served — happens where bodies are kept and receipts outlive history (blockswith--prune.receipts.distance=keep-all), which is the largest group of the 26 red cells above.Second live node:
blockswith receipts keptSepolia at tip with
--prune.mode=blocks --prune.include-receipts --prune.receipts.distance=keep-all— every body and every receipt on disk, state history pruned below block 11238830. Old block probed: 5750484, some 5.5M blocks below the history boundary.erigon_getHeaderByNumber,debug_getRawHeadereth_getBlockByNumber,eth_getBlockTransactionCountByNumber,eth_getUncleCountByBlockNumber,eth_getTransactionByHasheth_getBlockReceipts,debug_getRawReceipts,eth_getTransactionReceipt,erigon_getBlockReceiptsByBlockHash,erigon_getLogsByHash,ots_getBlockDetails,ots_getBlockTransactionsmainall seven refuseeth_getLogs,erigon_getLogseth_getLogs,erigon_getLogshistory is availablemainboth return an empty arrayeth_getBalance,trace_block,debug_traceBlockByNumber,ots_hasCodedebug_getRawBlock,debug_getRawTransactionThis is the configuration where the change is behavioural rather than a difference in wording.
eth_getBlockReceiptsreturns 101 receipts for a block whose state history is long gone,ots_getBlockDetailscomputes itstotalFees, anderigon_getLogsByHashreturns all 101 log positions — none of whichmainwill answer. In the other direction a filteredeth_getLogsnow refuses instead of quietly returning[], while the unfiltered form still answers with its 125 logs.36 of 38 checks pass; the two failures are
debug_getRawBlockanddebug_getRawTransaction, whose fix is in the follow-up PR.Topic shapes on the same node
The empty-topic-position case, probed on block 5750932 of the same datadir with an rpcdaemon opened read-only:
topics"topics": []"topics": [null]PrunedError"topics": [[]]PrunedError"topics": [null, null]PrunedErrorThe last row keeps 1113 rather than 1117 because that shape requires two topic positions to match, so it drops the four logs carrying a single topic; the filter is working, the gate is not involved. A query filtered by address still refuses naming
history is available, and the single-block receipt endpoints stay served, so the blocks leg added in point 4 introduces no false refusal where bodies are kept.Gate boundaries measured against the data
Each gate names a boundary; a second
rpcdaemonbuilt withcheckPruneFieldshort-circuited tonilsays where the data actually starts. Both were opened read-only on the same Sepoliablocksdatadir — head 11501094, declared boundary 11238950 — and the first block that answers correctly was found by bisection, with a block's own receipts as the ground truth for what its log endpoints must reproduce.checkPruneHistoryeth_getBalance,eth_getCodecheckPruneHistorytrace_block,debug_traceBlockByNumbercheckLogsAvailableeth_getLogs,erigon_getLogsfilteredcheckBlockReceiptsAvailableeth_getBlockReceipts,debug_getRawReceipts,ots_getBlockDetails,ots_getBlockTransactionscheckLogsAvailableeth_getLogs,erigon_getLogsunfilteredThe 75k-block gap is not a defect: file retirement is floored to the file step, so data survives below the declared boundary until the next prune pass removes it. The gate promises the boundary the configuration guarantees rather than the physical one that moves at every retire, which is the pre-existing behaviour of
checkPruneField.The index leg is where the change earns its place. Filtering the same block by an address taken from its own logs:
Below the physical floor the filtered query answers silently zero, which is what the history leg turns into
PrunedError. The physical floor of the log index, 11163681, coincides with that of state history, 11163680 — the premise this PR rests on, measured rather than assumed. The unfiltered form is correct at every block probed: 1117, 1016, 1360, 525, 443, 1237 and 216 logs, each matching its receipts, identical with and without the gate.Point 5 was found the same way: blocks 1000, 1000000, 1510087 and 2000000 go from the
PrepareEnverror to an empty list, while blocks carrying transactions are unchanged — 800000 keeps 1 receipt, 1511431 keeps 4, 5750932 keeps 99, 11501058 keeps 94.The blocks leg is the one this datadir cannot exercise, since it keeps every body; it stays covered by the unit tests.
Follow-up
The endpoints that need the block body but not the receipts are left to a second PR:
debug_getRawBlock,debug_getRawTransactionanderigon_getBlockByTimestampstill gate on history, so under--prune.mode=blocksthey refuse bodies that were never pruned — the defect class #21965 reported. That PR also gateseth_feeHistory, which has none today, and completes the table with the header, trace and Otterscan search endpoints.