Skip to content

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

Open
amir-deris wants to merge 15 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 15 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.
  • Windowed litt range scantryFilterLogsRange walks the requested
    range in successive rangeQueryWindowBlocks-sized windows (currently 4)
    instead of one FilterLogs call over the whole range. Each window's raw
    candidates are normalized against the query-wide budget and discarded
    before the next window is requested, so transient candidate memory does
    not accumulate across the full range. The hard memory bound is still the
    byte budget; windowing is an intentional memory/latency tradeoff on top
    of that.
  • 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).

Latency tradeoff (windowed litt path)

Because each window is only 4 blocks wide, litt's per-query fan-out
(receipt-store.log-filter-parallelism, default 16) is effectively capped
at 4 concurrent block scans for the duration of that window — even though
the store is configured for higher parallelism. For a full 2000-block range
this is roughly 500 sequential store calls / scan waves instead of
~ceil(2000/16) = 125 waves under a single full-range call (~4× more waves
on store-bound wide queries). That is an intentional memory/latency
tradeoff: the byte budget is what actually bounds memory; the small window
lets candidates be dropped between chunks. Widening the window toward
log-filter-parallelism would recover most of the lost concurrency without
weakening the byte cap.

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, plus window-boundary
    and between-window cancellation coverage for tryFilterLogsRange.
  • 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
Changes eth_getLogs semantics (errors vs truncation/unbounded success) and can reject heavy indexer queries; mitigated by configurable limits and tests, but RPC clients may need narrower filters.

Overview
eth_getLogs now fails with clear errors when a query would exceed memory limits, instead of building huge result sets or silently truncating.

A new receipt.LogBudget charges each matched log at append time against max_log_no_block (default 10k, now for bounded and open-ended ranges) and max_log_bytes (new, default 64 MiB). Over either limit returns ErrTooManyLogs / ErrTooManyLogBytes.

ReceiptStore.FilterLogs takes *LogBudget instead of an optional count-only limit; the litt tag-index path enforces the budget in parallel workers and respects request context cancellation. The EVM RPC range path uses 4-block windows and a byte-only budget at the store (raw tag matches can over-count before normalization), then applies the full count+byte cap when rebuilding EVM-visible logs. The block-by-block fallback uses the same budget with early abort.

Breaking: open-ended queries that used to be truncated at the log cap now error; bounded queries that previously had no log cap can also fail above the default limit.

Reviewed by Cursor Bugbot for commit 1fbe678. 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 24, 2026, 4:18 PM

Comment thread evmrpc/filter.go Outdated
@codecov

codecov Bot commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 59.21%. Comparing base (b39a8d2) to head (1fbe678).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3759      +/-   ##
==========================================
- Coverage   60.18%   59.21%   -0.97%     
==========================================
  Files        2327     2235      -92     
  Lines      194602   184043   -10559     
==========================================
- Hits       117112   108979    -8133     
+ Misses      66918    65348    -1570     
+ Partials    10572     9716     -856     
Flag Coverage Δ
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/config/config.go 78.19% <ø> (ø)
evmrpc/filter.go 68.05% <ø> (+1.05%) ⬆️
evmrpc/server.go 89.04% <ø> (-0.15%) ⬇️
sei-db/ledger_db/receipt/litt_receipt_store.go 58.62% <ø> (-0.36%) ⬇️
sei-db/ledger_db/receipt/litt_tag_index.go 75.64% <ø> (-1.47%) ⬇️
sei-db/ledger_db/receipt/receipt_store.go 66.83% <ø> (-0.18%) ⬇️
x/evm/keeper/keeper.go 45.98% <ø> (ø)

... and 92 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 Outdated
Comment thread evmrpc/filter.go Outdated
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
Comment thread evmrpc/filter.go Outdated

@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 Outdated

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 Outdated
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.

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 windowing change in ddd2038 ("Added window log scan for litt store...") rewrote this exact code path but does not fix the issue this comment raised — it just shrinks the window the doubling can occur in.

candidates is now scoped to a single rangeQueryWindowBlocks-sized window (4 blocks) and passed into normalizeRangeQueryLogs as candidateLogs, same as before. Inside normalizeRangeQueryLogs, candidateLogs is still only read once (to build blockNumbers) and then remains live through the parameter binding for the rest of the function while rebuilt grows — nothing was changed there.

Critically, storeCandidateBudget() is still a fresh, independent max_log_bytes byte-only budget created per window call, while the rebuild's budget is the cumulative, shared budget passed into tryFilterLogsRange across all windows. So in the worst case, a single 4-block window can itself accumulate close to max_log_bytes of candidate data (e.g. a few logs with very large Data payloads), and that candidates slice stays reachable for the duration of that window'''s normalizeRangeQueryLogs call while the already-accumulated result from prior windows plus the in-progress rebuilt slice (bounded together by the shared budget) also approach max_log_bytes. Peak matched-log heap on this path can still reach ~2x max_log_bytes in that scenario — the same bound described in the original comment, just re-scoped to a window instead of the whole query range.

The originally suggested fix still applies: extract blockNumbers from candidateLogs up front in normalizeRangeQueryLogs and drop the reference (e.g. set candidateLogs = nil, or take only []uint64 as the parameter) before the rebuild loop runs, so the per-window candidate slice can't stay live alongside the rebuild's growing allocation.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Still not fixed as of the latest commits (dddb9aa, 6202f73), which only touch context-cancellation handling in litt_tag_index.go / filter.go and don't change normalizeRangeQueryLogs's candidateLogs handling. candidateLogs is still passed by reference and stays live for the whole call while normalizeRangeQueryLogs builds a second, independently-budgeted rebuilt/result slice, so the transient ~2x max_log_bytes peak described in the original comment (and reiterated by seidroid[bot] on 2026-07-24T12:13:48Z) is unchanged. The suggested fix is still open: extract blockNumbers from candidateLogs up front and drop the reference (e.g. candidateLogs = nil after building blockNumbers, or take only []uint64 as the parameter) before the rebuild loop runs.

@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 Outdated

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

@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 pushes count + byte budgets into the receipt store so eth_getLogs aborts instead of accumulating unbounded results. No correctness blockers found; a couple of non-blocking notes on peak-memory accounting and the documented behavior change.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • Peak-memory bound is ~2x max_log_bytes, not the single 64 MiB the config/PR text implies (Codex P2, confirmed): on the range path the accumulated normalized result is bounded by the query-wide authoritative budget (~max_log_bytes) while each window also mints a fresh byte-only storeCandidateBudget() that can materialize up to max_log_bytes of transient candidates. Still bounded (windows are only 4 blocks, so candidate sets are usually small), but consider clarifying the max_log_bytes doc comment to state it bounds the result, with transient per-window candidate memory on top.
  • Intentional breaking behavior change: bounded eth_getLogs queries matching more than max_log_no_block (default 10000) now error with ErrTooManyLogs where they previously succeeded, and open-ended queries now error instead of silently truncating. This is documented in the CHANGELOG and PR body and the PR is labeled non-app-hash-breaking; flagging only for operator/client awareness.
  • Cursor's second-opinion review file (cursor-review.md) was empty — that pass produced no output. Codex's review contributed the peak-memory note above.
  • Minor: filterLogsByTags preallocates the merged slice with make([]*ethtypes.Log, 0, budget.UsedCount()); with a byte-only store-candidate budget (maxLog=0) UsedCount() can be large but is bounded by the byte cap, so this is fine — just noting the capacity hint is driven by the byte budget rather than a count cap on that path.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread evmrpc/filter.go
// #nosec G115 -- windowTo is a block height which fits in int64
sdkCtx := f.ctxProvider(int64(windowTo)).WithContext(ctx)

candidates, err := store.FilterLogs(sdkCtx, windowFrom, windowTo, crit, f.storeCandidateBudget())

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] Each window calls f.storeCandidateBudget(), minting a fresh byte-only budget (up to max_log_bytes) per window. Combined with the query-wide budget that keeps prior windows' normalized results resident (also up to max_log_bytes), peak matched-log memory is bounded to roughly 2x max_log_bytes rather than the single 64 MiB the config comment suggests. Not a correctness issue (peak stays bounded, and windows are only 4 blocks so candidate sets are typically small), but worth reflecting in the max_log_bytes doc comment.

Comment thread sei-db/ledger_db/receipt/litt_tag_index.go
Comment thread evmrpc/filter.go
Comment on lines 306 to 314
if filterConfig.maxLog <= 0 {
filterConfig.maxLog = DefaultMaxLogLimit
}
if filterConfig.maxLogBytes <= 0 {
filterConfig.maxLogBytes = receipt.DefaultMaxLogBytes
}

shutdownCtx, shutdownCancel := context.WithCancel(context.Background())
logFetcher := &LogFetcher{

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 new maxLog<=0 → DefaultMaxLogLimit coercion this PR adds and documents (in NewFilterAPI, evmrpc/filter.go:306-308) is never applied on the WS eth_subscribe(logs) path: NewSubscriptionAPI (evmrpc/subscribe.go) assigns the raw, uncoerced FilterConfig straight onto the LogFetcher. So an operator who sets evm.max_log_no_block=0 — following the doc comment this PR adds — gets the coerced 10000-log cap on HTTP eth_getLogs/eth_newFilter, but an uncapped matched-log count on WS log subscriptions (only the byte budget remains as a backstop there). Fix by applying the same <=0 coercion in NewSubscriptionAPI, or coercing MaxLogNoBlock/MaxLogBytes once in ReadConfig so every consumer sees an already-normalized value.

Extended reasoning...

This PR introduces a documented contract for Config.MaxLogNoBlock: "a non-positive value falls back to DefaultMaxLogLimit." That contract is implemented in exactly one place — NewFilterAPI (evmrpc/filter.go:306-308), which runs if filterConfig.maxLog <= 0 { filterConfig.maxLog = DefaultMaxLogLimit } (and the analogous coercion for maxLogBytes) before constructing its LogFetcher. NewFilterAPI backs the HTTP eth_getLogs / eth_newFilter+eth_getFilterChanges endpoints wired in server.go.

The WS eth_subscribe(logs) path is wired differently: NewEVMWebSocketServer builds a LogFetcher inline (with no filterConfig set at all) and passes a separate &FilterConfig{maxLog: config.MaxLogNoBlock, maxLogBytes: config.MaxLogBytes, maxBlock: config.MaxBlocksForLog} straight into NewSubscriptionAPI. NewSubscriptionAPI (evmrpc/subscribe.go:49-50) does logFetcher.filterConfig = filterConfig with no coercion whatsoever — it never calls or shares logic with NewFilterAPI. So on this path, maxLog/maxLogBytes are whatever the operator configured, unmodified, including a non-positive value.

The Logs subscription loop (subscribe.go, calling a.logFetcher.GetLogsByFilters) reads limit := f.filterConfig.maxLog (evmrpc/filter.go:855) and feeds it to f.newLogBudget(limit)receipt.NewLogBudget(limit, maxLogBytes). LogBudget.Reserve's count check is b.maxLog > 0 && newCount > b.maxLog (log_budget.go) — so a maxLog of 0 or less disables the count dimension entirely, silently, rather than falling back to DefaultMaxLogLimit as the new doc comment promises. The byte budget is separately coerced per-use inside newLogBudget/storeCandidateBudget (if maxLogBytes <= 0 { maxLogBytes = receipt.DefaultMaxLogBytes }), so it does still apply on this path — maxLog is the one dimension that only ever gets coerced inside NewFilterAPI, and nowhere else.

Step-by-step proof: (1) Operator sets evm.max_log_no_block = 0 in app.toml, following exactly the semantics this PR's own new doc comment describes ("a non-positive value falls back to DefaultMaxLogLimit"). (2) A client calls eth_getLogs over HTTP: NewFilterAPI runs at startup, sees filterConfig.maxLog <= 0, sets it to DefaultMaxLogLimit (10000) once, and every query through that FilterAPI/LogFetcher is capped at 10000 matched logs as documented. (3) A client instead calls eth_subscribe("logs", ...) over WS: the LogFetcher backing NewSubscriptionAPI was constructed with maxLog: config.MaxLogNoBlock (== 0) directly, no coercion ever ran on it. (4) The subscription's poll loop calls GetLogsByFilterstryFilterLogsRange/block-by-block fallback with limit = 0, so LogBudget.maxLog = 0, and Reserve's b.maxLog > 0 guard is false for every call — the matched-log count is never capped on this path, contradicting the very doc comment this PR adds. Only the byte budget (default 64 MiB, coerced per-use) remains as a backstop.

Nothing in the existing code prevents this: NewSubscriptionAPI genuinely never touches NewFilterAPI or shares its coercion logic, so the PR's stated goal — enforcing max_log_no_block "for both bounded and open-ended block ranges" with a documented fallback for non-positive values — is not actually reached on the WS subscription entry point.

This is a narrow issue in practice: it requires an operator to explicitly set max_log_no_block <= 0 (the shipped default, 10000, makes the coercion a no-op everywhere), the byte budget still bounds peak memory on this path so there is no OOM risk, and the runtime behavior for this specific edge case on the WS path is unchanged from before this PR (the prior applyOpenEndedLogLimit gate was also maxLog > 0-gated). What the PR does change is introducing the documented <=0 → DefaultMaxLogLimit contract while only enforcing it on one of the two entry points, plus extending the count-cap goal to a path that still doesn't receive it. A clean fix is to apply the same <=0 coercion inside NewSubscriptionAPI (mirroring NewFilterAPI), or, more robustly, to coerce MaxLogNoBlock/MaxLogBytes once inside config.ReadConfig so every consumer of Config already sees normalized values and no call site can forget the check.

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 WS eth_subscribe(logs) path is untouched by this PR: evmrpc/subscribe.go is not among the changed files, and NewSubscriptionAPI (evmrpc/subscribe.go:49-50) still does logFetcher.filterConfig = filterConfig with no <=0 coercion. The FilterConfig built for it in NewEVMWebSocketServer (evmrpc/server.go) only gained the new maxLogBytes field; maxLog is still passed through raw from config.MaxLogNoBlock with no fallback to DefaultMaxLogLimit. So an operator setting evm.max_log_no_block=0 still gets an uncapped matched-log count on WS log subscriptions, contradicting the doc comment this PR adds in evmrpc/config/config.go. The fix (coerce in NewSubscriptionAPI, or centralize the coercion in config.ReadConfig) has not been applied.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Still unaddressed as of the latest commits: evmrpc/subscribe.go's NewSubscriptionAPI (line 50) still does 'logFetcher.filterConfig = filterConfig' with no <=0 coercion, and evmrpc/server.go still builds that FilterConfig directly from config.MaxLogNoBlock/config.MaxLogBytes with no fallback to DefaultMaxLogLimit/DefaultMaxLogBytes. This is the third confirmation of the same gap (see the 2026-07-24T12:08:29Z follow-up). Leaving open.

Comment thread evmrpc/filter.go
Comment on lines 48 to 58
// global request rate limit, only applies to queries > RPSLimitThreshold
GlobalRPSLimit = 30
RPSLimitThreshold = 100 // block range queries below this threshold bypass rate limiting

// rangeQueryWindowBlocks bounds how many blocks tryFilterLogsRange asks the
// litt store to materialize per call, so raw candidates are discarded
// between windows instead of accumulating over the whole query range.
rangeQueryWindowBlocks = 4
)

// BlockCacheEntry for sotring block, bloom, and receipts cache

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 tryFilterLogsRange now scans the litt receipt store in strictly sequential rangeQueryWindowBlocks=4-sized windows instead of one call over the whole range, so each window's litt fan-out is capped at 4 concurrent block scans even though the configured LogFilterParallelism defaults to 16. For a full 2000-block range this is roughly 500 sequential store round trips instead of ~125, a real latency regression (~4x for wide ranges) on the primary littidx eth_getLogs path that isn't mentioned in the PR description. It's an intentional memory/latency tradeoff (the byte budget is what actually bounds memory), so consider widening the window to match logFilterParallelism to recover most of the lost concurrency.

Extended reasoning...

What changed

Before this PR, tryFilterLogsRange issued a single store.FilterLogs(sdkCtx, fromBlock, toBlock, crit) call over the entire requested range. Inside the litt store, filterLogsByTags fans that range out across an errgroup capped at s.logFilterParallelism (default DefaultReceiptLogFilterParallelism = 16, see sei-db/config/receipt_config.go:28), so a wide query could scan up to 16 blocks concurrently.

After this PR, tryFilterLogsRange walks [fromBlock, toBlock] in a plain sequential for loop, stepping by rangeQueryWindowBlocks = 4 (evmrpc/filter.go:55). Each iteration calls store.FilterLogs for just that 4-block window and fully awaits it — plus normalizeRangeQueryLogs — before advancing to the next window. Since each window contains at most 4 blocks, filterLogsByTags's nBlocks is <= 4, so the same errgroup.SetLimit(logFilterParallelism) can now only ever schedule 4 concurrent block scans, no matter how high logFilterParallelism is configured. Windows never overlap, so there is no pipelining to make up the difference.

Impact

For the default maxBlock = 2000 range, this turns what used to be ~125 rounds of 16-way-parallel block scans into ~500 sequential rounds of 4-way-parallel scans — roughly a 4x wall-clock regression on the litt-backed eth_getLogs range-query path, which is the primary/fast path this PR itself is built around. The regression applies even to sparse or no-match queries, since each window still requires its own store round trip to check the tag index. None of this is called out in the PR description or CHANGELOG, which otherwise document the new memory-bounding behavior in detail.

Why the window size doesn't need to be this small

The windowing itself is a deliberate and reasonable choice — it discards raw litt candidates between windows so the per-window byte budget (storeCandidateBudget(), driven by max_log_bytes) bounds memory regardless of how wide the overall query range is. But the size chosen for that window (4) is far below the configured fan-out (16 by default), so it throttles concurrency without any corresponding memory benefit: the byte budget is enforced per-window and is exactly as effective whether the window is 4 blocks or 16 blocks wide. A window sized to (or a small multiple of) logFilterParallelism would preserve full concurrency inside each window while still bounding per-window memory the same way.

Step-by-step illustration

  1. Client issues eth_getLogs with fromBlock=1000, toBlock=3000 (2000 blocks, the maxBlock default) against a littidx-backed node with default config (logFilterParallelism = 16).
  2. Pre-PR: one store.FilterLogs(1000, 3000, crit) call fans out across the errgroup, scanning up to 16 blocks concurrently — roughly 125 "rounds" of parallel work to cover 2000 blocks.
  3. Post-PR: tryFilterLogsRange loops windowFrom := 1000; windowFrom <= 3000; windowFrom += 4, issuing 500 separate store.FilterLogs calls of width 4, each blocking on its own normalizeRangeQueryLogs call before the next window starts. Each call's internal errgroup can schedule at most 4 goroutines (nBlocks <= 4), regardless of logFilterParallelism=16.
  4. Net effect: ~500 serial round trips instead of ~125 parallel rounds — an approximately 4x latency increase for this query, with no corresponding gain (the byte budget bounds memory the same way at window size 4 or 16).

Why this is a nit rather than a blocker

This is a real, quantifiable performance regression on a primary RPC path, but it is not a correctness issue — results are unchanged, nothing crashes, and it reflects an intentional (if perhaps under-tuned) memory-vs-latency tradeoff that the PR author can reasonably choose to accept or tune in a follow-up. The default 120s filter timeout also makes outright query failures from this alone unlikely. Recommend raising rangeQueryWindowBlocks to match (or be a multiple of) logFilterParallelism, or pipelining window fetches, to recover most of the lost concurrency without giving up the memory-bounding benefit.

@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 caps for eth_getLogs (LogBudget) pushed into the receipt store, windowed litt range scanning, and context threading; the design is sound, correctness looks solid, and it is extensively tested. No blockers — a few non-blocking notes, chiefly a ~2× peak-memory weakening from per-window fresh byte budgets and an inaccurate PR-description "rename".

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

Blockers

  • None at the file/PR level.

Non-blocking

  • Breaking behavior change (documented in CHANGELOG/description, flagging for operator awareness): bounded eth_getLogs queries that previously returned arbitrarily large result sets now error with ErrTooManyLogs once matches exceed max_log_no_block (default 10000); open-ended queries that were silently truncated now also error. Ensure downstream clients/indexers handle the new error rather than treating it as an empty result.
  • PR-description inaccuracy: the description states applyOpenEndedLogLimit was renamed to applyOpenEndedBlockWindow, but no such function exists — the open-ended block-windowing branch in fetchBlocksByCrit was removed entirely. This is safe (that branch was effectively dead: both FilterAPI.GetLogs and GetLogsByFilters reject blockRange > maxBlock before fetchBlocksByCrit runs, so its blockRange > maxBlock clamp was unreachable), but the description should be corrected to say 'removed' not 'renamed'.
  • Latency tradeoff (acknowledged in the description): rangeQueryWindowBlocks=4 caps effective litt fan-out at 4 concurrent block scans regardless of receipt-store.log-filter-parallelism (default 16), ~4× more scan waves on wide store-bound queries. Consider widening the window toward log-filter-parallelism, which recovers concurrency without weakening the byte cap.
  • Cursor's second-opinion pass produced no output (empty cursor-review.md); only the Codex pass contributed a finding (the 2× memory note below).
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread evmrpc/filter.go
// #nosec G115 -- windowTo is a block height which fits in int64
sdkCtx := f.ctxProvider(int64(windowTo)).WithContext(ctx)

candidates, err := store.FilterLogs(sdkCtx, windowFrom, windowTo, crit, f.storeCandidateBudget())

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 can reach ~2× max_log_bytes rather than the ~1× the PR contract implies. Each window is given a fresh full-size byte budget via storeCandidateBudget() (maxBytes = full max_log_bytes), while the query-wide budget separately accumulates the bytes of already-normalized results held in result. Late in a wide query, result can be near max_log_bytes at the same time a single window materializes candidates near max_log_bytes, so transient peak ≈ 2× the configured cap. Still bounded (not the pre-PR unbounded case), so not a blocker, but consider charging window candidates against the remaining query-wide byte budget (e.g. derive the store budget from max_log_bytes - budget.usedBytes) so the stated hard bound actually holds. (Raised by Codex.)

@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 1fbe678. Configure here.

logs, err = s.FilterLogs(newTestCtxAtHeight(6).WithContext(canceledCtx), 6, 6, crit, nil)
require.NoError(t, err)
require.Empty(t, 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.

Canceled context test mismatch

Medium Severity

TestFilterLogsByTagsPreCanceledContextReturnsEmptyFast and TestFilterLogsThreadsSDKContext expect a pre-canceled context to yield empty logs with no error, but filterLogsByTags returns ctx.Err() after Wait. That conflicts with sibling tests that require context.Canceled. Matching the new expectations would let a canceled single-window range eth_getLogs return an empty success.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1fbe678. Configure here.

@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.

Well-structured, thoroughly tested change that bounds eth_getLogs peak memory via a matched-log count cap and a new estimated-heap-byte cap threaded through the receipt store and both query paths. No correctness blockers; the main substantive note is that the per-log heap estimate undercounts the real footprint, so the effective byte ceiling runs ~40% above the configured value.

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.
  • Intentional latency tradeoff: the 4-block rangeQueryWindowBlocks caps effective litt fan-out at 4 concurrent block scans regardless of the configured receipt-store.log-filter-parallelism (default 16), roughly 4x more scan waves on wide store-bound queries. Documented by the author; consider widening the window toward log-filter-parallelism to recover concurrency without weakening the byte cap.
  • Breaking behavior change (documented in PR/CHANGELOG): bounded eth_getLogs queries matching more than max_log_no_block now error with ErrTooManyLogs where they previously succeeded, and open-ended queries that were silently truncated now error. Ensure operators/clients are aware.
  • 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] The fixed per-log overhead here undercounts the real heap footprint. EstimateLogHeapBytes charges 116 fixed bytes (Address 20 + topics header 24 + pointer 8 + struct 64), but an ethtypes.Log value is ~168 bytes on 64-bit Go plus the 8-byte result-slice slot pointer (~176 B fixed) — the 64-byte logHeapStructOverhead models the struct as ~40% smaller than it is. For collections of small logs the estimate is ~1.4x low, so actual peak heap can exceed the configured max_log_bytes by tens of MiB at the 64 MiB default, partially undermining the intended ceiling. Memory stays proportionally bounded so this isn't blocking, but consider raising logHeapStructOverhead (or deriving it from unsafe.Sizeof(ethtypes.Log{})) so the byte cap reflects true retained memory. (Matches Codex's finding.)

Comment on lines +228 to 236
}
eg.Go(func() error {
blockLogs, err := s.blockLogs(fromBlock+uint64(i), groups, crit) //nolint:gosec // i < nBlocks
if egCtx.Err() != nil {
return nil // group already cancelled; skip without overwriting the cause
}
blockLogs, err := s.blockLogs(egCtx, fromBlock+uint64(i), groups, crit, budget) //nolint:gosec // i < nBlocks
if err != nil {
return err
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 In filterLogsByTags (sei-db/ledger_db/receipt/litt_tag_index.go:228-236), after eg.Wait() succeeds the code checks the outer ctx.Err() (not egCtx) and returns that error. When ctx is already canceled before the call starts, no blocks are scheduled and eg.Wait() returns nil, but this check then returns context.Canceled instead of an empty result — directly contradicting the PR's own test TestFilterLogsByTagsPreCanceledContextReturnsEmptyFast, which requires NoError+Empty for this exact scenario. This is a reproducible, CI-blocking test failure as shipped.

Extended reasoning...

The bug. filterLogsByTags fans a block range out across an errgroup. Before scheduling each block it checks egCtx.Err() and breaks the loop if the group is already cancelled; after eg.Wait() returns, it adds one more check:

if err := eg.Wait(); err != nil {
    return nil, err
}
if err := ctx.Err(); err != nil {
    return nil, err
}

This second check reads the outer ctx (the caller's request context), not egCtx (the errgroup-derived context used everywhere else in this function). When the caller's ctx is already canceled before filterLogsByTags is even called, errgroup.WithContext(ctx) yields an egCtx that is also already done. The scheduling loop's if egCtx.Err() != nil { break } therefore fires on the very first iteration (i=0), so zero goroutines are ever scheduled. eg.Wait() has nothing to wait on and returns nil (no goroutine ran, so nothing set an error). Execution then falls through to the new ctx.Err() check, which is non-nil (the ctx was canceled by the caller), and the function returns (nil, context.Canceled).

Why this is wrong. The PR ships its own test, TestFilterLogsByTagsPreCanceledContextReturnsEmptyFast, specifically documenting the intended contract for this scenario:

// ... a context already canceled before the fan-out starts short-circuits the
// whole scan: errgroup.WithContext observes the parent as already done, so
// no per-block worker is scheduled and the call returns immediately with no
// logs and no error (mirroring the empty-range early return, not a budget trip).
func TestFilterLogsByTagsPreCanceledContextReturnsEmptyFast(t *testing.T) {
    ...
    ctx, cancel := context.WithCancel(context.Background())
    cancel()
    logs, err := s.filterLogsByTags(ctx, 1, 5, filters.FilterCriteria{Addresses: []common.Address{addr}}, nil)
    require.NoError(t, err)
    require.Empty(t, logs)
}

The test's own doc comment states the pre-canceled case should 'return immediately with no logs and no error' — i.e. mirror the existing empty-range early return (if fromBlock > toBlock { return nil, nil }), not surface an error. But the shipped code does the opposite: it returns context.Canceled. This is a direct contradiction between the code and the test in the same PR, and it is not a matter of interpretation — the test's own comment spells out the intended behavior.

Why nothing else catches this. The mid-scan cancellation path (a context that gets canceled while blocks are already being scanned) is a separate, intentional behavior this PR adds elsewhere: some blocks may have started, get cancelled partway, and the caller legitimately wants context.Canceled surfaced rather than a silently-partial result (see the sibling per-log/per-tx ctx.Err() checks inside blockLogs/candidateBlockLogs). The bug here is that the pre-canceled, nothing-scheduled case was special-cased in the test/comment as 'act like the already-known-empty case,' but the implementation never actually distinguishes 'ctx died before we started anything' from 'ctx died partway through' — both paths land on the same trailing ctx.Err() check.

Step-by-step proof (reproduced):

  1. Build a littReceiptStore and write 5 blocks worth of receipts matching an address filter.
  2. Create a context.Context and cancel it before calling filterLogsByTags.
  3. Call s.filterLogsByTags(ctx, 1, 5, crit, nil).
  4. errgroup.WithContext(ctx) returns egCtx that is already Done().
  5. The scheduling loop's first iteration hits egCtx.Err() != nil and breaks — no eg.Go calls happen.
  6. eg.Wait() returns nil immediately (no goroutines were ever launched).
  7. ctx.Err() is checked and is context.Canceled (the caller canceled it) → function returns (nil, context.Canceled).
  8. The test asserts require.NoError(t, err) and require.Empty(t, logs), so it fails with 'Received unexpected error: context canceled'.

I ran this exact test to confirm:

go test ./sei-db/ledger_db/receipt/... -run TestFilterLogsByTagsPreCanceledContextReturnsEmptyFast -v
--- FAIL: TestFilterLogsByTagsPreCanceledContextReturnsEmptyFast (0.04s)
    litt_ctx_internal_test.go:182: Error Trace: .../litt_ctx_internal_test.go:182
        Error: Received unexpected error: context canceled

Impact beyond the failing test. This also means every eth_getLogs call on the litt range path whose request context is already canceled right as the query starts (e.g. a client disconnects at the very start of a range query) surfaces as a hard error, rather than the graceful empty result the PR's own test and comment say is intended — inconsistent with the deliberate mid-scan-cancellation behavior implemented elsewhere in the same PR.

Suggested fix. Special-case the 'nothing was ever scheduled' condition explicitly (e.g. track whether the loop broke before scheduling any goroutine and skip the trailing ctx.Err() check in that case), or drop the trailing ctx.Err() check's use of the outer ctx in favor of a check that only fires when something was actually cancelled mid-flight. Either way, the fix must make the shipped test pass as intended, since a failing unit test blocks CI (go-test.yml runs go test -race).

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.

2 participants