Skip to content

feat(evmrpc): bound eth_getLogs peak memory with matched-log count and byte budgets#3759

Open
amir-deris wants to merge 10 commits into
mainfrom
amir/plt-779-fully-bound-logs-memory
Open

feat(evmrpc): bound eth_getLogs peak memory with matched-log count and byte budgets#3759
amir-deris wants to merge 10 commits into
mainfrom
amir/plt-779-fully-bound-logs-memory

Conversation

@amir-deris

@amir-deris amir-deris commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

eth_getLogs could materialize an unbounded result set: a wide block range or
loose filter that matched millions of logs, or logs with large Data
payloads, would allocate them all before returning, spiking peak memory. The
block-range and open-ended windowing caps limited how many blocks were
scanned, but nothing capped how many matching logs — or how many bytes of
log data — were held in memory at once.

This PR pushes two independent caps into the receipt store so a query aborts
instead of accumulating past the limit:

  • Matched-log count cap — existing max_log_no_block config
    (evm.max_log_no_block, default 10000), now enforced for both bounded
    and open-ended block ranges, not just open-ended ones.
  • Estimated heap byte cap — new max_log_bytes config
    (evm.max_log_bytes, default 64 MiB), bounding the total estimated heap
    footprint (Address + Data + Topics + per-log overhead) of the
    in-memory result.

Both are tracked by a new receipt.LogBudget: Reserve(log) is charged per
matched log at append time and returns an error the moment either ceiling is
exceeded, so overflow is a clear error (ErrTooManyLogs / ErrTooManyLogBytes)
rather than silent truncation.

Where the caps are enforced

  • ReceiptStore.FilterLogs now takes a *LogBudget instead of a bare
    limit int64; nil disables all caps (preserves prior behavior for
    internal callers, benches, and the simulator).
  • litt range path (filterLogsByTagsblockLogs
    candidateBlockLogs) — the parallel fan-out charges budget.Reserve per
    matched log; the worker that trips the budget returns its error, cancelling
    the errgroup so already-scheduled blocks bail before reading.
  • Range-query byte/count split — the litt store's raw tag-index match
    count can include synthetic/non-EVM-visible logs that eth's
    normalizeRangeQueryLogs later drops. Enforcing the count cap on that raw
    count would produce false-positive ErrTooManyLogs errors for queries
    whose true (EVM-visible) result is under the limit. To avoid this,
    tryFilterLogsRange passes the litt store a byte-only budget
    (storeCandidateBudget(), maxLog=0) — bounding materialization memory
    without prematurely capping on count — and the authoritative count +
    byte cap is enforced afterward in normalizeRangeQueryLogs, against the
    normalized, EVM-visible log set.
  • Block-by-block fallback (GetLogsByFilters) — pooledCollector.Append
    charges the same LogBudget per log; workers stop materializing blocks and
    the fan-out stops queueing batches once the budget trips, then the overflow
    is reported after wg.Wait.
  • RenameapplyOpenEndedLogLimitapplyOpenEndedBlockWindow to
    reflect that it windows the block range; the matched-log cap is enforced
    separately by the LogBudget.

No new config surface beyond max_log_bytes; max_log_no_block is reused
for the count cap, now with corrected doc comments (see below).

Config doc corrections

  • max_log_no_block doc comment now states it applies to both bounded
    and open-ended ranges and that a non-positive value falls back to
    DefaultMaxLogLimit, rather than the stale "0 disables the cap" claim
    (unreachable — NewFilterAPI always coerces <= 0 to the default).
  • Added CHANGELOG.md entry for this PR.

⚠️ Behavior change (breaking)

  • Open-ended queries (missing fromBlock or toBlock) that matched more
    than max_log_no_block logs were previously silently truncated via
    mergeSortedLogs(..., limit). They now return ErrTooManyLogs instead.
  • Bounded queries (both fromBlock and toBlock set) previously had
    no matched-log cap and could return arbitrarily large result sets
    successfully. They are now capped too: a bounded query matching more than
    max_log_no_block (default 10000) logs will now fail with
    ErrTooManyLogs where it previously succeeded.
  • Clients that relied on either of the above must narrow their block range
    or filter criteria.

Testing performed to validate your change

  • log_budget_test.go: count/byte ceilings, concurrent Reserve overshoot
    bounds, byte-only budget behavior.
  • littidx_test.go (TestLittIdxFilterLogsLimit and related): FilterLogs
    returns all logs at or below the limit, returns the budget's error when
    exceeded, and treats a nil/disabled budget as uncapped.
  • filter_range_cap_test.go: end-to-end coverage of the range-query path's
    byte-only store budget + authoritative post-normalization count/byte cap,
    including the synthetic-log false-positive scenario.
  • filter_test.go / filter_budget_internal_test.go: block-by-block
    fallback budget enforcement and early-abort behavior.
  • Updated all existing FilterLogs call sites (tests, benches, simulator,
    the pebble backend stub, and fakeReceiptStore in
    watermark_manager_test) to the new *LogBudget signature.
  • Existing litt receipt-store suite (ordering, multi-part, reopen, prune,
    parallel-order) passes unchanged with the new parameter.

Thread a limit param through the ReceiptStore.FilterLogs interface so log
queries abort with ErrTooManyLogs once matches exceed the cap instead of
materializing an unbounded result set. Enforce it in both the litt range
path (errgroup-cancelled fan-out) and the block-by-block fallback
(cooperative early-abort), bounding peak memory to O(maxLog).

Rename applyOpenEndedLogLimit to applyOpenEndedBlockWindow to reflect
that it windows the block range; the log cap is now enforced separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Code review skipped — your organization has reached its monthly code review spending cap.

An organization admin can view or raise the cap at claude.ai/admin-settings/claude-code. The cap resets at the start of the next billing period.

Once the cap resets or is raised, push a new commit or reopen this pull request to trigger a review.

@cursor

cursor Bot commented Jul 15, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Breaking RPC behavior for large eth_getLogs responses (errors replace silent truncation; bounded queries gain a count cap). Changes are localized to filter/receipt paths with extensive tests, but operators and indexers may need narrower queries or higher limits.

Overview
Adds receipt.LogBudget and wires it through eth_getLogs so oversized queries fail with an error instead of growing unbounded in-memory result sets.

Config: New max_log_bytes (evm.max_log_bytes, default 64 MiB) caps estimated heap for matched logs. max_log_no_block now limits matched-log count for both bounded and open-ended ranges (not only open-ended). Reserve(log) enforces count and bytes per appended log (ErrTooManyLogs / ErrTooManyLogBytes).

Enforcement paths: FilterLogs takes *LogBudget (nil = uncapped for benches/simulator). The litt tag-index path charges the budget during parallel fan-out and cancels workers on trip. The range path uses a byte-only budget at the store (avoids false count caps from tag-index noise) and applies the full count+byte cap after normalizeRangeQueryLogs. The block-by-block fallback stops batching/workers when the budget trips.

Breaking behavior: Open-ended queries that used to be silently truncated at the log cap now error. Bounded queries that previously had no log-count cap are now capped at max_log_no_block.

Reviewed by Cursor Bugbot for commit 408426a. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedJul 23, 2026, 8:31 AM

Comment thread evmrpc/filter.go Outdated
@codecov

codecov Bot commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 74.50980% with 39 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.14%. Comparing base (96fa55d) to head (408426a).

Files with missing lines Patch % Lines
evmrpc/filter.go 64.28% 15 Missing and 10 partials ⚠️
sei-db/ledger_db/receipt/litt_tag_index.go 55.55% 4 Missing and 4 partials ⚠️
sei-db/ledger_db/receipt/log_budget.go 92.00% 2 Missing and 2 partials ⚠️
evmrpc/config/config.go 33.33% 1 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3759      +/-   ##
==========================================
- Coverage   60.20%   59.14%   -1.06%     
==========================================
  Files        2328     2220     -108     
  Lines      194502   182262   -12240     
==========================================
- Hits       117104   107807    -9297     
+ Misses      66849    64947    -1902     
+ Partials    10549     9508    -1041     
Flag Coverage Δ
sei-chain-pr 69.81% <74.50%> (?)
sei-db 70.41% <ø> (ø)
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
evmrpc/server.go 89.18% <100.00%> (ø)
sei-db/ledger_db/receipt/litt_receipt_store.go 58.97% <100.00%> (ø)
sei-db/ledger_db/receipt/receipt_store.go 67.67% <100.00%> (+0.66%) ⬆️
x/evm/keeper/keeper.go 46.37% <100.00%> (+0.39%) ⬆️
evmrpc/config/config.go 77.48% <33.33%> (-0.71%) ⬇️
sei-db/ledger_db/receipt/log_budget.go 92.00% <92.00%> (ø)
sei-db/ledger_db/receipt/litt_tag_index.go 76.00% <55.55%> (-1.11%) ⬇️
evmrpc/filter.go 73.59% <64.28%> (+6.58%) ⬆️

... and 193 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid, well-tested change that bounds eth_getLogs peak memory by pushing a matched-log cap into ReceiptStore.FilterLogs; the implementation and call-site updates are correct. Main concern is an undocumented behavior change: the cap is sourced from MaxLogNoBlock (documented as open-ended-only) but now also errors on previously-uncapped bounded queries, and the config doc is now stale.

Findings: 0 blocking | 5 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Config semantics drift: MaxLogNoBlock (mapstructure max_log_no_block) is documented in evmrpc/config/config.go:103 and the config template (~line 699) as "max number of logs returned if block range is open-ended." This PR now uses it as a hard error threshold for ALL queries (bounded and open-ended). Update those doc comments to reflect that it now (a) applies to bounded queries and (b) causes an ErrTooManyLogs error rather than truncating returned logs. Consider whether a distinct, clearly-named general cap setting is warranted rather than overloading the open-ended one.
  • Breaking-change coverage: the PR description's "breaking" section calls out the open-ended truncation→error switch but not that bounded eth_getLogs requests matching more than MaxLogNoBlock (default 10000) logs — previously uncapped and successful — now return ErrTooManyLogs. This should be surfaced in the changelog/release notes so client integrations can adjust.
  • Cursor's second-opinion review file (cursor-review.md) was empty — that pass produced no output. REVIEW_GUIDELINES.md was also empty, so no repo-specific standards were applied.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread evmrpc/filter.go
// both bounded and open-ended queries. Exceeding it is an error (not
// silent truncation), so peak memory stays bounded to the cap. A value of 0
// disables the cap.
limit := f.filterConfig.maxLog

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] limit is sourced from MaxLogNoBlock, whose config doc reads "max number of logs returned if block range is open-ended." On main, bounded queries were uncapped on both the range path (tryFilterLogsRange passed no limit) and the block-by-block path (limit only applied when applyOpenEndedLogLimit). Applying limit unconditionally here silently repurposes an open-ended-only setting into a hard error threshold for bounded queries: a bounded eth_getLogs matching more than MaxLogNoBlock (default 10000) logs now returns ErrTooManyLogs where it previously succeeded. If this broader cap is intended, update the max_log_no_block doc comment (config.go:103 and the config template) and the changelog; otherwise consider preserving the open-ended-only semantics or introducing a distinct general-cap setting.

Comment thread evmrpc/filter.go Outdated
break
}
before := len(localLogs)
f.GetLogsForBlockPooled(block, crit, &localLogs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Nit: the cap is checked only between blocks, so GetLogsForBlockPooled materializes a full block's matching logs before collected is incremented. Peak matched-log memory is therefore limit + (workers × single-block logs), not strictly limit. This is inherent (at least one block must be materialized) and matches the PR's documented "cap plus at most one in-flight block per worker" — memory stays bounded — so no change needed, just flagging that the bound is cap + overshoot rather than the cap alone.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushes a matched-log cap into the receipt store to bound eth_getLogs peak memory; the concurrency/cancellation logic is sound, but the config documentation claims 0 disables the cap (it's coerced to the default) and the range path counts synthetic logs that later get normalized away, so eth_getLogs can spuriously return ErrTooManyLogs.

Findings: 0 blocking | 6 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor's second-opinion review file (cursor-review.md) is empty — that pass produced no output.
  • The PR description flags a breaking behavior change (open-ended queries over maxLog now return ErrTooManyLogs instead of silently truncating), and the author notes it 'must be called out in the changelog/docs', but the diff contains no changelog/doc update. Add one before merge.
  • Nit: the drain comment in GetLogsByFilters (filter.go:922-923) says producer goroutines could be 'left blocked on a full buffer', but fetchBlocksByCrit fully buffers res (size = range) and closes it before returning, so producers never block and the channel is already closed when drained — the loop is harmless cleanup but the stated rationale is inaccurate.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread evmrpc/config/config.go Outdated

// max number of logs returned if block range is open-ended
// max number of logs a single eth_getLogs query may match before it errors,
// for both bounded and open-ended block ranges (0 disables the cap)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This doc says (0 disables the cap), but NewFilterAPI coerces any maxLog <= 0 to DefaultMaxLogLimit (10000) at filter.go:287-288, so an operator setting max_log_no_block: 0 gets the default cap, not an uncapped query. Either document that 0 falls back to the default, or skip the coercion when the value is explicitly 0 to actually allow disabling. (Flagged by Codex P2.)

Comment thread evmrpc/filter.go Outdated
// maxLog caps the number of matching logs a single query may return, for
// both bounded and open-ended queries. Exceeding it is an error (not
// silent truncation), so peak memory stays bounded to the cap. A value of 0
// disables the cap.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Same inaccuracy as the config doc: "A value of 0 disables the cap" isn't reachable here — limit comes from f.filterConfig.maxLog, which NewFilterAPI has already forced to a positive DefaultMaxLogLimit when the configured value was <= 0. At this call site limit is always > 0.

Comment thread evmrpc/filter.go Outdated
sdkCtx := f.ctxProvider(int64(toBlock))

logs, err := store.FilterLogs(sdkCtx, fromBlock, toBlock, crit)
logs, err := store.FilterLogs(sdkCtx, fromBlock, toBlock, crit, limit)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The range path applies the cap against the store's pre-normalization count. filterLogsByTags counts every indexed matching log (including synthetic/Cosmos receipts), but normalizeRangeQueryLogs (called just below) rebuilds only from EVM-visible txs — for the eth_* namespace includeSyntheticReceipts=false, so synthetic logs are dropped afterward. Result: eth_getLogs can return ErrTooManyLogs even when the actual RPC result is below maxLog. The block-by-block fallback counts correctly (its collectLogs already excludes synthetic), so the two paths are inconsistent. Consider capping against the normalized count, or documenting that the range-path cap is a conservative over-count. (Codex P1.)

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A well-structured change that pushes a matched-log cap into both eth_getLogs query paths to bound peak memory, with consistent error-on-overflow semantics and good test coverage for both paths. No correctness blockers; the main gaps are documentation of a config field that cannot actually disable the cap and a broader client-visible breaking change that lacks a changelog entry.

Findings: 0 blocking | 4 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Breaking change is under-documented and broader than the PR description states. The description frames the break as only "open-ended queries now error instead of silently truncating." But because limit := f.filterConfig.maxLog is now passed unconditionally to both the litt range path and the block-by-block fallback, bounded queries (both fromBlock and toBlock set) are now capped too — previously they had no matched-log cap and would return a large result set successfully. A bounded query matching more than maxLog (default 10000) logs within the block window will now return ErrTooManyLogs where it previously succeeded. Add a CHANGELOG.md entry and call out the bounded-query behavior change explicitly (Codex raised the missing-changelog point).
  • cursor-review.md was empty — the Cursor second-opinion pass produced no output, so only Codex's findings (both incorporated here) were available to merge.
  • Minor: in GetLogsByFilters the post-wg.Wait() drain loop for range blocks {} is effectively unnecessary and its comment ("producer goroutines are not left blocked on a full buffer") is inaccurate — fetchBlocksByCrit sizes the channel buffer to the full block range (make(chan ..., end-begin+1)), so producers can never block on a full buffer. Harmless, but the rationale should be corrected or the loop dropped.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread evmrpc/config/config.go Outdated

// max number of logs returned if block range is open-ended
// max number of logs a single eth_getLogs query may match before it errors,
// for both bounded and open-ended block ranges (0 disables the cap)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This comment says 0 disables the cap, but that is not achievable via config: NewFilterAPI replaces any maxLog <= 0 with DefaultMaxLogLimit (10000) at evmrpc/filter.go:287-289, and the default for MaxLogNoBlock is already 10000 (config.go:237). So operators cannot disable the cap by setting max_log_no_block = 0 as documented — it silently becomes 10000. Either drop the "(0 disables the cap)" claim here (and mirror it in the limit comment at filter.go:33-34) or actually honor 0 by not defaulting it in NewFilterAPI. (Raised by Codex.)

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR pushes a matched-log cap into the receipt store to bound eth_getLogs peak memory, returning ErrTooManyLogs instead of silently truncating. The implementation is sound, well-documented, and well-tested; no blockers. The main non-blocking concern (also raised by Codex) is that the range path enforces the cap on the raw store count before normalization, which can produce a false ErrTooManyLogs for queries whose EVM-visible result is within the limit.

Findings: 0 blocking | 6 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Behavior change worth emphasizing: on main, bounded block-range queries had no matched-log cap and would return arbitrarily large result sets successfully; they now error once the match count exceeds max_log_no_block (default 10000). The PR description only calls out the open-ended truncation→error change as breaking, but the new cap on bounded queries is an additional behavior change. It is captured in the CHANGELOG, but any client-facing docs should also state that wide bounded queries returning >max_log_no_block logs will now fail.
  • Test coverage: the end-to-end RPC test (TestFilterGetLogsMatchedLogCap) uses a blockHash query, which skips the range path and exercises only the block-by-block fallback. The litt range path's cap is covered at the store level (TestLittIdxFilterLogsLimit) but there is no end-to-end test exercising tryFilterLogsRange + normalizeRangeQueryLogs + the cap together through the JSON-RPC handler.
  • Cursor's second-opinion review file (cursor-review.md) was empty — that pass produced no output.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread evmrpc/filter.go Outdated
sdkCtx := f.ctxProvider(int64(toBlock))

logs, err := store.FilterLogs(sdkCtx, fromBlock, toBlock, crit)
logs, err := store.FilterLogs(sdkCtx, fromBlock, toBlock, crit, limit)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The cap is enforced by the store on the raw tag-index match count, which includes non-EVM-visible / synthetic logs, before normalizeRangeQueryLogs filters the result down to the EVM-visible set. Since normalized <= raw, a query whose EVM-visible result is within limit can still trip ErrTooManyLogs when the raw match count exceeds it — a false positive that the block-by-block path (which counts already-normalized logs via GetLogsForBlockPooled) does not have. Consider whether the range path should cap on the EVM-visible count for consistency, or document that the cap is intentionally a conservative pre-normalization bound. (Matches Codex's finding.)

Comment thread evmrpc/filter.go Outdated

// Re-check the cap against the normalized (EVM-visible) count, which the
// store's tag-index count does not match.
if limit > 0 && int64(len(normalized)) > limit {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This post-normalization re-check appears to be dead code: it only fires when len(normalized) > limit, but the store already aborts with ErrTooManyLogs whenever the raw count exceeds limit, and normalized <= raw. So whenever the store returns successfully (raw <= limit), normalized <= limit too and this branch can never trigger. It's harmless as defense-in-depth, but it cannot recover the false-positive case described above — it can only tighten the cap further, never loosen it. A brief comment clarifying it's defensive would help, or it could be removed.

Comment thread evmrpc/filter.go Outdated
var limit int64
if applyOpenEndedLogLimit {
limit = f.filterConfig.maxLog
// Drain the blocks channel so its producer goroutines are not left blocked

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The rationale in this comment is slightly inaccurate. fetchBlocksByCrit sizes the channel buffer to the full range (end-begin+1) and blocks on errChan until every producer goroutine has finished and res is closed, so by the time blocks is returned here the producers have already completed — they are never "left blocked on a full buffer." The drain loop is still fine (it just releases buffered ResultBlocks for GC and terminates on the closed channel), but the comment's justification doesn't hold. Consider rewording to reflect that it's freeing buffered blocks rather than unblocking producers.

Comment thread sei-db/ledger_db/receipt/litt_tag_index.go
Comment thread evmrpc/filter.go Outdated
Comment thread evmrpc/filter.go
Comment thread evmrpc/filter.go
Comment thread sei-db/ledger_db/receipt/litt_tag_index.go
@amir-deris amir-deris changed the title feat(evmrpc): bound eth_getLogs peak memory with a matched-log cap feat(evmrpc): bound eth_getLogs peak memory with matched-log count and byte budgets Jul 22, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a9fdb56. Configure here.

Comment thread evmrpc/filter.go

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds count + estimated-heap-byte budgets to bound eth_getLogs peak memory, threaded cleanly through the receipt store and both fetch paths with thorough tests and correct nil/concurrency handling. No blocking issues; a few non-blocking notes around peak memory and the documented breaking behavior change.

Findings: 0 blocking | 5 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Behavior change awareness: bounded eth_getLogs queries (both fromBlock and toBlock set) previously had no matched-log cap and now error with ErrTooManyLogs above max_log_no_block (default 10000), and open-ended queries that were silently truncated now error. This is intentional and documented in the PR/CHANGELOG, but it can break existing indexers/clients — worth calling out to operators. Consider adding a metric/counter for rejected (over-cap) queries for observability.
  • Range path peak memory (Codex): tryFilterLogsRange holds the store's byte-only-budgeted candidate slice while normalizeRangeQueryLogs builds a second byte-budgeted rebuilt slice, so peak retained log memory can approach 2x max_log_bytes rather than the single per-query bound the config comment implies. Acceptable as an OOM guard, but the config doc for max_log_bytes could note the ~2x worst case, or the candidate slice could be released earlier.
  • Store-side byte-only budget can produce a false-positive ErrTooManyLogBytes: the raw tag-index candidate byte total (which may include synthetic / non-EVM-visible logs that normalization later drops) is what's charged against max_log_bytes at the store, so a query whose EVM-visible result is small could still be rejected if its raw candidates are byte-heavy. This mirrors the count false-positive the PR deliberately avoids; consider documenting that the byte cap is enforced on raw candidates, not the normalized result.
  • Cursor produced no review output (cursor-review.md was empty).
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread evmrpc/filter.go

return f.normalizeRangeQueryLogs(ctx, logs, crit)
budget := f.newLogBudget(limit)
normalized, err := f.normalizeRangeQueryLogs(ctx, logs, crit, budget)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Peak memory on this path can approach ~2x max_log_bytes: logs (the store's byte-only-budgeted candidate slice) stays referenced as candidateLogs for the duration of normalizeRangeQueryLogs, while rebuilt grows under its own byte budget. normalizeRangeQueryLogs only needs candidateLogs to derive the unique block numbers and a capacity hint up front. Consider extracting blockNumbers first and dropping the candidate slice before the rebuild loop, or documenting the ~2x worst case on max_log_bytes. Non-blocking — the caps still prevent unbounded growth.

Comment thread evmrpc/filter.go
Comment on lines 1047 to 1057
return []*ethtypes.Log{}, nil
}

return f.normalizeRangeQueryLogs(ctx, logs, crit)
budget := f.newLogBudget(limit)
normalized, err := f.normalizeRangeQueryLogs(ctx, logs, crit, budget)
if err != nil {
return nil, err
}

return normalized, nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The litt-backed range path in tryFilterLogsRange keeps the store's byte-budgeted candidate slice (max_log_bytes) alive as normalizeRangeQueryLogs builds a second, independently-budgeted rebuilt slice, so both can be simultaneously live and peak matched-log heap on this path can approach ~2x max_log_bytes instead of a hard cap. Fixing it just means extracting the block numbers from candidateLogs up front and letting the candidate slice go before the rebuild loop runs.

Extended reasoning...

What the bug is

tryFilterLogsRange fetches candidate logs from the litt store under a byte-only budget capped at max_log_bytes (f.storeCandidateBudget()), storing them in logs (evmrpc/filter.go:1041). That slice is then passed into normalizeRangeQueryLogs as the candidateLogs parameter. Inside normalizeRangeQueryLogs, candidateLogs is only read once, at the very top, to build the blockSet/blockNumbers slice (evmrpc/filter.go:1080-1086) — after that point nothing in the function touches it again.

However, the parameter (and the caller-side logs variable that still references the same backing array) remains in scope for the entire duration of normalizeRangeQueryLogs, which then builds a brand new rebuilt slice via a pooledCollector under a second, completely independent receipt.LogBudget (f.newLogBudget(limit), also capped at max_log_bytes). Because Go does not null out a still-referenced parameter and the caller keeps logs live in its own frame, the garbage collector cannot reclaim candidateLogs while rebuilt is growing.

Why this defeats the byte cap

The two slices hold genuinely distinct backing data, not aliases of the same bytes: candidateLogs’ per-log Data comes from the litt store’s getLogsForTx/convertLog path, while rebuilt’s per-log Data is freshly copied from cached receipts fetched via getOrSetCachedReceipt inside the rebuild loop. So in the worst case, both slices independently approach the full max_log_bytes ceiling at the same time — meaning peak heap for matched-log data on this path can approach roughly 2x the configured budget (e.g. ~128 MiB with the 64 MiB default), even though each individual budget object correctly enforces its own cap in isolation. This directly undercuts the PR’s stated goal of bounding eth_getLogs peak memory to max_log_bytes.

Why nothing today prevents it

There is no code path that drops or truncates candidateLogs before or during the rebuild loop — it is simply held by the candidateLogs parameter binding until the function returns. The existing budgets (storeCandidateBudget() for the store fetch and newLogBudget(limit) for the rebuild) are each correctly enforced independently, but neither is aware of the other’s live allocation, so their sum is what actually bounds peak heap, not either one alone.

Step-by-step proof

  1. A wide-range eth_getLogs query hits the litt-backed range path in tryFilterLogsRange.
  2. store.FilterLogs(...) returns logs, a slice of *ethtypes.Log whose cumulative estimated heap footprint is close to max_log_bytes (the byte-only budget just barely let it through).
  3. tryFilterLogsRange calls f.normalizeRangeQueryLogs(ctx, logs, crit, budget), passing logs as candidateLogs.
  4. Inside, blockNumbers is derived from candidateLogs in the first ~15 lines — at this point candidateLogs’ actual payload (the Data/Topics bytes) is no longer needed for anything.
  5. Despite that, candidateLogs remains referenced by the still-live parameter for the rest of the function while the loop over blockNumbers fetches receipts and appends newly-built logs into rebuilt via collector.Append, which itself charges a second max_log_bytes-capped budget.
  6. If rebuilt also grows to near max_log_bytes before the budget trips (a realistic case, since the normalized/EVM-visible result can be close in size to the raw candidate set), both candidateLogs (max_log_bytes) and rebuilt (max_log_bytes) are simultaneously reachable, so total live matched-log heap approaches ~2x max_log_bytes — not the single-cap bound the config name and PR description promise.

Fix

Extract blockNumbers (and hasFilters/filterIndexes, which don’t depend on candidateLogs at all) before doing anything else, and avoid retaining candidateLogs as a live binding for the rest of the call — e.g. take only the small derived slice as input to the rest of the function, or explicitly stop referencing the parameter once blockNumbers is built. This tightens the true worst-case bound back down to a single max_log_bytes, matching the cap the config value is meant to enforce. This does not need to block merge: the growth is still bounded to a constant (2x) multiple of a configurable value, not unbounded, and does not cause a crash, correctness issue, or data loss on its own.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A well-structured, thoroughly-tested change that bounds eth_getLogs peak memory via a per-log count+byte LogBudget. The enforcement logic is correct across the litt range path, the range-normalization path, and the block-by-block fallback (mutex-guarded reservations, errgroup cancellation on trip, nil-budget preserving prior behavior). No blocking issues; only minor observations and an intentional, documented breaking behavior change.

Findings: 0 blocking | 5 non-blocking | 0 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor's second-opinion review (cursor-review.md) is empty — that pass produced no output. Codex reported no material issues.
  • Intentional breaking behavior change (well-documented in the PR body): bounded queries — and blockHash queries — that previously returned large/uncapped result sets will now fail with ErrTooManyLogs once they exceed max_log_no_block (default 10000). Confirm downstream consumers / integrations are aware, since this can turn previously-succeeding calls into errors after upgrade.
  • On the litt range path, peak heap can reach ~2x max_log_bytes transiently: the store materializes candidate logs bounded by the byte-only budget, and normalizeRangeQueryLogs rebuilds a second bounded slice while the candidates are still referenced. This is acceptable and arguably out of scope, but the effective ceiling is roughly double the configured 64 MiB for that path — worth a note in the config doc if precision matters.
  • Minor layering: evmrpc/config/config.go now imports sei-db/ledger_db/receipt solely for the DefaultMaxLogBytes constant, coupling the config package to the storage package. Not a problem, but consider whether the default belongs in a lower-level shared location.
  • newLogBudget/storeCandidateBudget re-coerce maxLogBytes<=0 to the default even though NewFilterAPI already coerces filterConfig.maxLogBytes; the redundant guard is harmless but could be dropped.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A well-structured and thoroughly tested change that bounds eth_getLogs peak memory via a new receipt.LogBudget (matched-log count + estimated heap byte caps), threaded cleanly through the FilterLogs interface with nil meaning uncapped. No blockers; two accuracy caveats on the memory bound are worth noting but non-blocking.

Findings: 0 blocking | 4 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Range-query path can hold roughly 2× max_log_bytes at once (Codex): the litt store returns byte-budgeted candidateLogs (up to max_log_bytes), and that slice stays referenced by logs in tryFilterLogsRange while normalizeRangeQueryLogs independently builds rebuilt under a second max_log_bytes budget. Peak is still bounded but ~2× the advertised ceiling. Additionally, the store materializes full log Data for candidates, yet normalizeRangeQueryLogs only consumes their BlockNumber — the byte budget is spent on payloads that are immediately discarded. Consider dropping the candidateLogs reference (or returning only block numbers from the store) before rebuilding.
  • Cursor second-opinion review file (cursor-review.md) was empty — that pass produced no output.
  • Minor: logs := make([]*ethtypes.Log, 0, budget.UsedCount()) in filterLogsByTags preallocates 0 capacity when the budget is nil (uncapped) — same as the prior var logs behavior, so no regression, but the prealloc optimization is lost for uncapped internal callers.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

// slot pointer, struct fields, and topics slice header. This bounds peak
// heap for OOM prevention, not JSON-RPC wire size.
logHeapPointerOverhead = int64(8)
logHeapStructOverhead = int64(64)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] logHeapStructOverhead = 64 undercounts the real ethtypes.Log struct. On 64-bit, the struct is ~160 bytes (Address 20 + Topics header 24 + Data header 24 + BlockNumber 8 + TxHash 32 + TxIndex 8 + BlockHash 32 + Index 8 + Removed 1, with alignment). Since EstimateLogHeapBytes also adds the address (20) and topics-header (24) separately, the combined fixed estimate (~116B) is still well below the true per-log heap (~200B incl. the pointer and topics backing array). For a count-heavy query of small logs this lets actual memory run ~30-40% over the configured max_log_bytes before the budget trips. Since this is an OOM-prevention bound, consider raising the constant to reflect the actual struct size (~160B, or drop the separately-counted address/topics-header and use ~200B flat).

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds count and byte budgets (LogBudget) to bound peak memory for eth_getLogs across the litt range path and the block-by-block fallback. The implementation is careful and well-tested; I found no blocking correctness issues, only a minor peak-memory observation on the range path and a note that the Cursor pass produced no output.

Findings: 0 blocking | 4 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor's second-opinion review (cursor-review.md) was empty — that pass produced no output. Codex's single P2 finding (range-path peak memory ~2x max_log_bytes) is captured as an inline suggestion below.
  • Intentional breaking change: bounded queries (both fromBlock and toBlock set) are now capped at max_log_no_block (default 10000) and will error with ErrTooManyLogs where they previously succeeded; open-ended queries now error instead of silently truncating. This is documented in the PR body and CHANGELOG.md — flagging only so reviewers/operators are aware clients may need to narrow ranges.
  • Nice defensive touches worth acknowledging: nil-budget is handled uniformly (Reserve/Tripped/Err/UsedCount all nil-safe, pooledCollector guards c.budget != nil), the litt fan-out uses a byte-only budget so the authoritative count cap is applied deterministically in normalizeRangeQueryLogs rather than nondeterministically across parallel workers, and the drain loop is safe because fetchBlocksByCrit fully buffers and closes its channel before returning.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread evmrpc/filter.go

return f.normalizeRangeQueryLogs(ctx, logs, crit)
budget := f.newLogBudget(limit)
normalized, err := f.normalizeRangeQueryLogs(ctx, logs, crit, budget)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Minor (matches Codex P2): on the range path, store.FilterLogs above materializes the candidate slice bounded by max_log_bytes (via storeCandidateBudget()), and normalizeRangeQueryLogs then rebuilds a second slice also bounded by max_log_bytes while logs is still referenced by this frame. Transient peak retained log memory can therefore approach ~2x max_log_bytes rather than the single stated bound. normalizeRangeQueryLogs only needs logs to collect the unique block numbers up front, so setting logs = nil after that extraction (or documenting the 2x transient) would tighten the guarantee. Not blocking.

Comment thread evmrpc/filter.go
Comment on lines 840 to 862
return nil, 0, fmt.Errorf("block range too large (%d), maximum allowed is %d blocks", blockRange, f.filterConfig.maxBlock)
}

// maxLog caps the number of matching logs a single query may return, for
// both bounded and open-ended queries. Exceeding it is an error (not
// silent truncation), so peak memory stays bounded to the cap. NewFilterAPI
// coerces a non-positive value to DefaultMaxLogLimit, so limit is always > 0
// here; downstream helpers still treat limit <= 0 as uncapped.
limit := f.filterConfig.maxLog

// blockHash queries must use the hash-aware block fetch path below.
// Range-query receipt stores only constrain by numeric block range and
// do not enforce crit.BlockHash.
if crit.BlockHash == nil {
// Try efficient range query first
// #nosec G115 -- begin and end are validated to be positive block heights above
if logs, rangeErr := f.tryFilterLogsRange(ctx, uint64(begin), uint64(end), crit); rangeErr == nil {
if logs, rangeErr := f.tryFilterLogsRange(ctx, uint64(begin), uint64(end), crit, limit); rangeErr == nil {
return logs, end, nil
} else if !errors.Is(rangeErr, receipt.ErrRangeQueryNotSupported) {
// If it's a real error (not just unsupported), return it
// A real error (including receipt.ErrTooManyLogs) short-circuits;
// only "unsupported" falls through to the block-by-block path.
return nil, 0, rangeErr
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 GetLogsByFilters now hard-errors (ErrTooManyLogs/ErrTooManyLogBytes) once matched logs exceed maxLog/maxLogBytes, but GetFilterChanges' LogsSubscription branch only advances filter.lastToHeight on success and leaves it unchanged on error. Since lastToHeight is frozen and latest only grows, a poll-based filter (eth_newFilter + eth_getFilterChanges) whose backlog window accumulates more matching logs than the cap fails identically on every subsequent poll forever, with no in-band recovery short of discarding the filter (which silently skips the backlog). Before this PR the same scenario truncated the result but still advanced the cursor, so this permanent wedge is a new regression.

Extended reasoning...

The bug. GetLogsByFilters (evmrpc/filter.go:840-862 and the block-by-block fallback further down) now enforces limit := f.filterConfig.maxLog unconditionally, for both bounded and open-ended ranges, via the new LogBudget. When the matched-log count or estimated byte total exceeds the cap, it returns receipt.ErrTooManyLogs / receipt.ErrTooManyLogBytes on both the range-query path (tryFilterLogsRange short-circuits any non-ErrRangeQueryNotSupported error) and the block-by-block fallback (budget.Err() after wg.Wait).

The code path that triggers it. FilterAPI.GetFilterChanges's LogsSubscription branch calls a.logFetcher.GetLogsByFilters(ctx, filter.fc, filter.lastToHeight). On error, it returns immediately (if err != nil { return nil, err }) before reaching the write-lock block that sets updatedFilter.lastToHeight = lastToHeight + 1. Only the success path advances the cursor. The query window used internally is [max(fromBlock, lastToHeight), latest] (ComputeBlockBounds), so once lastToHeight stops advancing, every subsequent call re-queries an equal-or-wider window (since latest only grows over time).

Why nothing today prevents it. Before this PR, the range path had no matched-log cap at all (bounded queries never capped; open-ended queries silently truncated via mergeSortedLogs(limit) while still returning end, so lastToHeight always advanced even when data was dropped). There was no scenario in which GetLogsByFilters returned an error attributable to matched-log volume — errors were reserved for range/parameter validation, which don't recur identically as latest grows. This PR is what introduces a volume-triggered error into a caller that has no retry/narrowing logic for that specific error.

Impact. Once a poll-based filter's [lastToHeight, latest] window accumulates more matching logs than maxLog (default 10000) — e.g. a filter created a few thousand blocks back on a busy contract, or simply a client that polls infrequently while matching activity is high — the very first (or some subsequent) eth_getFilterChanges call trips the cap and errors. Since the cursor never moves and the window can only grow, every future poll on that filter id fails with the same error, forever. The filter itself doesn't expire (each poll refreshes lastAccess), so it sits in a permanently broken state. The only workaround is to uninstall and recreate the filter, which starts lastToHeight at the current height and silently skips the entire backlog the client was trying to consume — a worse outcome than before, where the same situation degraded gracefully via truncation-but-progress.

Step-by-step proof. Take maxLog = 10000, maxBlock = 2000 (defaults). A client creates a filter via eth_newFilter matching a high-volume event on a busy contract, at lastToHeight = 0. Over the next maxBlock-sized polling window, activity produces 12,000 matching logs — plausible at >5 matching logs/block, a realistic rate for a busy DEX or bridge contract. The client calls eth_getFilterChanges: GetLogsByFilters computes the window, matches 12,000 logs, exceeds maxLog, and returns ErrTooManyLogs. GetFilterChanges returns the error without touching filter.lastToHeight (still 0). The client retries a moment later: latest has advanced, so the window is now even wider and still contains at least the same 12,000+ logs — the call fails identically. This repeats indefinitely; there is no value of lastToHeight the client can supply (the API doesn't expose it) and no way to shrink the internal window, since eth_getFilterChanges accepts only a filter id, not per-call range parameters.

Note on the PR's documented breaking change. The PR description explicitly calls out that clients must "narrow their block range or filter criteria" when they hit the new cap. That guidance is actionable for one-shot eth_getLogs, where the caller controls fromBlock/toBlock directly. It does not apply to eth_getFilterChanges, where the range is entirely internal (lastToHeight managed by the server) and the client has no lever to pull — so this failure mode falls outside what the PR's intentional breaking-change note covers, and is better characterized as an unhandled interaction between the new cap and the existing polling-filter cursor logic.

Suggested fix. On a too-many-logs/bytes error from the LogsSubscription branch, GetFilterChanges should still be able to make forward progress — e.g. by advancing lastToHeight to some safe stopping point derived from what was scanned so far (mirroring the old truncate-and-advance behavior), or by returning a distinguishable error that instructs the client it must uninstall/recreate the filter with a narrower fc.Topics/fc.Addresses, rather than leaving the filter to fail identically forever.

Comment on lines +17 to +45
// slot pointer, struct fields, and topics slice header. This bounds peak
// heap for OOM prevention, not JSON-RPC wire size.
logHeapPointerOverhead = int64(8)
logHeapStructOverhead = int64(64)
logTopicsSliceHeader = int64(24)
)

// LogBudget tracks matched-log count and estimated heap bytes for eth_getLogs
// queries. Call Reserve before appending each log; Reserve returns an error
// without charging the budget when either ceiling would be exceeded (> limit,
// not >=). Concurrent Reserve calls may overshoot by ~O(workers) before Tripped
// is observed.
type LogBudget struct {
mu sync.Mutex
usedBytes int64
usedCount int64
maxBytes int64
maxLog int64
tripped atomic.Bool
tripErr error
}

func NewLogBudget(maxLog, maxBytes int64) *LogBudget {
return &LogBudget{maxLog: maxLog, maxBytes: maxBytes}
}

// NewLogBudgetBytesOnly caps estimated heap bytes without a matched-log count
// ceiling. Used by the litt store on the range-query path for early OOM abort
// before eth normalization rebuilds canonical logs.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 EstimateLogHeapBytes (sei-db/ledger_db/receipt/log_budget.go:17-45) undercounts the true retained heap of an ethtypes.Log by roughly 30-40% for small/zero-data logs, because it omits a slice header for Data and its struct-overhead constant (64B) is well below the real ~160B struct size. This lets a count-heavy small-log result overshoot the configured max_log_bytes OOM-prevention budget by a similar margin before Reserve trips. Note this duplicates the seidroid[bot] suggestion already posted on this PR (log_budget.go line 20) making the same observation.

Extended reasoning...

EstimateLogHeapBytes computes the per-log estimate as AddressLength(20) + len(Data) + logTopicsSliceHeader(24) + HashLength*len(Topics) + logHeapPointerOverhead(8) + logHeapStructOverhead(64). For a log with no Data and no Topics, that fixed portion is only 20+24+8+64 = 116 bytes.

The real ethtypes.Log struct is roughly 160 bytes on a 64-bit build (Address 20B, Topics slice header 24B, Data slice header 24B, BlockNumber 8B, TxHash 32B, TxIndex 8B, BlockHash 32B, Index 8B, Removed 1B padded, plus alignment), and the result slice itself retains an 8-byte *ethtypes.Log pointer per element. That puts the true fixed per-log retained heap at roughly 160-176 bytes — noticeably more than the 116 bytes the estimator charges. Two things drive the gap: the logHeapStructOverhead constant (64) is far below the real struct size (~160), and the estimator never adds a slice-header charge for Data (only Topics gets one via logTopicsSliceHeader), even though Data is itself a slice with its own 24-byte header cost independent of its length.

This estimate feeds directly into LogBudget.Reserve, which is the only thing standing between an eth_getLogs query and unbounded memory growth — it is charged per matched log on both the litt range path (storeCandidateBudget, byte-only) and the block-by-block fallback (pooledCollector.Append). Because the estimate is a per-log constant-time computation with no visibility into actual Go allocator behavior, no other code path double-checks or corrects it; whatever Reserve decides is charged is the only signal the budget acts on.

For a query that matches many small or zero-Data logs — exactly the workload where the count cap (maxLog) is least protective, since it only bounds count, not size — the under-charge compounds across every matched log. At the default max_log_bytes (64 MiB), actual retained heap for such a result set could run on the order of 30-40% past the configured ceiling before Reserve returns ErrTooManyLogBytes, i.e. an effective ceiling closer to ~85-90 MiB rather than 64 MiB. Since the whole point of this budget is to bound peak memory and prevent OOM, undercounting is specifically the unsafe direction here: a safety bound should over-estimate cost, not under-estimate it.

Concrete walkthrough: take a query matching N small logs, each with Address set, no Topics, and no Data. The estimator charges each one 116 bytes, so Reserve will admit up to floor(max_log_bytes / 116) logs before tripping — e.g. with the 64 MiB default, floor(67108864/116) ≈ 578525 logs. But the true retained heap per log (struct ~160B + 8B slice pointer ≈ 168B) means that many logs actually occupy 578525 * 168 ≈ 92.2 MiB, about 1.44x the intended 64 MiB cap. The budget still bounds memory to some multiple of the configured value (it does not regress to unbounded growth), but that multiple is materially larger than 1x for exactly the small-log-heavy workload the count cap does the least to protect against.

Fix: raise logHeapStructOverhead to reflect the real ~160-byte struct size (or, equivalently, add a Data slice-header charge and bump the struct constant closer to the true value) so the estimate is comfortably at or above the true retained footprint rather than ~30% under it.

This is the same observation independently raised by the seidroid[bot] reviewer earlier on this PR (inline suggestion on log_budget.go line 20, with matching numbers: "~116B" fixed estimate vs. "~200B" true footprint, "~30-40%" overage), so the author already has this feedback surfaced once; treating this as a duplicate is reasonable, but the underlying observation itself is technically sound. It does not change existing behavior or introduce a regression — the function is explicitly documented as an approximation, and memory usage remains bounded to a small, predictable multiple of the configured cap rather than growing unbounded — so it does not rise to a merge-blocking defect. A one-line constant adjustment closes most of the gap.

Comment thread evmrpc/filter.go
Comment on lines 1290 to 1311
}
begin, end, err := ComputeBlockBounds(latest, earliest, lastToHeight, crit)
if err != nil {
return nil, 0, false, err
return nil, 0, err
}

blockRange := end - begin + 1
if applyOpenEndedLogLimit && blockRange > f.filterConfig.maxBlock {
if applyOpenEndedBlockWindow && blockRange > f.filterConfig.maxBlock {
begin = end - f.filterConfig.maxBlock + 1
if begin < earliest {
begin = earliest
}
} else if !applyOpenEndedLogLimit && f.filterConfig.maxBlock > 0 && blockRange > f.filterConfig.maxBlock {
} else if !applyOpenEndedBlockWindow && f.filterConfig.maxBlock > 0 && blockRange > f.filterConfig.maxBlock {
// Use consistent error message format
return nil, 0, false, fmt.Errorf("block range too large (%d), maximum allowed is %d blocks", blockRange, f.filterConfig.maxBlock)
return nil, 0, fmt.Errorf("block range too large (%d), maximum allowed is %d blocks", blockRange, f.filterConfig.maxBlock)
}

if begin > end {
return nil, 0, false, fmt.Errorf("fromBlock %d is after toBlock %d", begin, end)
return nil, 0, fmt.Errorf("fromBlock %d is after toBlock %d", begin, end)
}

res := make(chan *coretypes.ResultBlock, end-begin+1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The open-ended block-range windowing added in fetchBlocksByCrit (guarded by applyOpenEndedBlockWindow) is unreachable dead code: GetLogsByFilters already unconditionally rejects any query with blockRange > maxBlock — including open-ended ones — using the identical ComputeBlockBounds inputs, before fetchBlocksByCrit is ever called. As a result, this PR's new doc comment claiming open-ended queries are "windowed down to the most recent maxBlock blocks rather than erroring" is inaccurate; such queries always error today. Recommend either removing the dead windowing branch or fixing the comment (and, if windowing is actually desired, relaxing the upstream check for open-ended queries).

Extended reasoning...

fetchBlocksByCrit's only caller is GetLogsByFilters. GetLogsByFilters computes begin, end via ComputeBlockBounds(latest, earliest, lastToHeight, crit), then unconditionally does:

blockRange := end - begin + 1
if blockRange > f.filterConfig.maxBlock {
    return nil, 0, fmt.Errorf("block range too large (%d), maximum allowed is %d blocks", blockRange, f.filterConfig.maxBlock)
}

This check fires for every query whose range exceeds maxBlock, open-ended or bounded, and runs before both the range-query path and the block-by-block fallback (fetchBlocksByCrit) are invoked.

fetchBlocksByCrit then re-derives begin/end from the same ComputeBlockBounds(latest, earliest, lastToHeight, crit) call (same lastToHeight, and latest/earliest sourced from the same watermarks a few lines apart), and only windows the range when:

if applyOpenEndedBlockWindow && blockRange > f.filterConfig.maxBlock {
    begin = end - f.filterConfig.maxBlock + 1
    ...
}

That is exactly the condition GetLogsByFilters already rejected upstream. So by the time this line runs, blockRange <= maxBlock always holds (barring an implausible microsecond-scale watermark advance between the two LatestHeight reads), and the windowing branch can never execute. The sibling else if !applyOpenEndedBlockWindow && ... error branch is equally unreachable for the same reason.

This PR renames the variable from applyOpenEndedLogLimit to applyOpenEndedBlockWindow and adds a new doc comment stating: "Open-ended queries (missing fromBlock or toBlock) have their block range windowed down to the most recent maxBlock blocks rather than erroring." That description does not match actual behavior — any open-ended query whose true range exceeds maxBlock errors in GetLogsByFilters (or in FilterAPI.GetLogs, which has the same duplicate unconditional check ahead of it) and never reaches the windowing code.

Step-by-step proof:

  1. Client calls eth_getLogs with fromBlock: 1, no toBlock (open-ended), on a chain where latest - 1 exceeds maxBlock (e.g. maxBlock = 2000, latest = 5000).
  2. GetLogsByFilters computes begin = 1, end = latest = 5000 via ComputeBlockBounds, so blockRange = 5000.
  3. Since 5000 > 2000, GetLogsByFilters immediately returns "block range too large (5000), maximum allowed is 2000 blocks"fetchBlocksByCrit is never called.
  4. Even if a receipt-store backend caused a fallback attempt, fetchBlocksByCrit would recompute begin = 1, end = 5000 from the same watermarks/criteria, hit applyOpenEndedBlockWindow && blockRange > maxBlock (true && 5000 > 2000), and would window begin — but this code path is dead because step 3 already returned.
  5. Net effect: the described windowing behavior never happens in production; every over-range open-ended query errors, contradicting the new comment.

This is real but low-impact: the unconditional early check both predates this PR and is functionally reasonable (erroring is a valid policy), so nothing actually breaks — clients still get a clear error rather than silently-wrong results. The PR's contribution is limited to the misleading rename/comment on top of pre-existing dead code. All four independent verifiers reached the same conclusion with no refutations, so I'm filing this as a documentation/dead-code cleanup item rather than a functional regression.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant