Skip to content
4 changes: 3 additions & 1 deletion sei-tendermint/cmd/tendermint/commands/reindex_event.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ func loadEventSinks(cfg *tmcfg.Config) ([]indexer.EventSink, error) {
if err != nil {
return nil, err
}
eventSinks = append(eventSinks, kv.NewEventSink(store))
// Reindexing only writes to the sink; it never searches, so the
// scan budget is irrelevant here.
eventSinks = append(eventSinks, kv.NewEventSink(store, nil))
case string(indexer.PSQL):
conn := cfg.TxIndex.PsqlConn
if conn == "" {
Expand Down
13 changes: 12 additions & 1 deletion sei-tendermint/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,13 @@ type RPCConfig struct {
// Maximum number of results returned by tx_search and block_search.
// 0 disables the cap (not recommended on public nodes).
MaxTxSearchResults int `mapstructure:"max-tx-search-results"`

// Maximum number of KV index entries that all in-flight tx_search and
// block_search requests may visit at once. This is a process-wide safety
// cap that bounds peak memory (and scan CPU) under broad or highly

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"bounds peak memory" overstates what the counter measures on the tx fallback path. The charge is one per index entry visited, but collectBounded then calls Get on every entry in filteredHashes, materializing the full transaction body and events, and only trims to opts.Limit after the whole set is built and sorted. So peak is bounded by the entries visited times the average tx size, in entries rather than bytes, and the result cap does not bound it. A someattr EXISTS over ~90k txs stays under the 100k default and materializes ~90k full EVM bodies before truncating to 10k. Block search is fine here since it yields int64 heights.

Two ways to close it, either is fine. Reword the claim to what the counter actually bounds, index entries scanned and thereby the fallback match-set, or apply opts.Limit as a hard cap on filteredHashes before the Get loop so the materialization is bounded by results kept. I lean towards the second since it makes the comment true rather than softening it.

// concurrent search load: it is shared across requests, not applied per-query.
// 0 disables the cap (not recommended on public nodes).
MaxSearchScanBudget int `mapstructure:"max-search-scan-budget"`
Comment thread
claude[bot] marked this conversation as resolved.
}

// DefaultRPCConfig returns a default configuration for the RPC server
Expand Down Expand Up @@ -561,7 +568,8 @@ func DefaultRPCConfig() *RPCConfig {
TimeoutReadHeader: 10 * time.Second,
TimeoutWrite: 30 * time.Second,

MaxTxSearchResults: 10_000,
MaxTxSearchResults: 10_000,
MaxSearchScanBudget: 100_000,
}
}

Expand Down Expand Up @@ -617,6 +625,9 @@ func (cfg *RPCConfig) ValidateBasic() error {
if cfg.MaxTxSearchResults < 0 {
return errors.New("max-tx-search-results can't be negative")
}
if cfg.MaxSearchScanBudget < 0 {
return errors.New("max-search-scan-budget can't be negative")
}
Comment thread
claude[bot] marked this conversation as resolved.
return nil
}

Expand Down
1 change: 1 addition & 0 deletions sei-tendermint/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ func TestRPCConfigValidateBasic(t *testing.T) {
"TimeoutReadHeader",
"TimeoutWrite",
"MaxTxSearchResults",
"MaxSearchScanBudget",
}

for _, fieldName := range fieldsToTest {
Expand Down
8 changes: 8 additions & 0 deletions sei-tendermint/config/toml.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,14 @@ timeout-write = "{{ .RPC.TimeoutWrite }}"
# Set to 0 to disable the cap (not recommended on public nodes).
max-tx-search-results = {{ .RPC.MaxTxSearchResults }}

# Maximum number of KV index entries that all in-flight tx_search and
# block_search requests may visit at once. This is a process-wide safety cap
# that bounds peak memory (and scan CPU) under broad or highly concurrent
# search load; it is shared across requests, not applied per-query. When the
# budget is exhausted an in-flight search fails rather than continuing to
# accumulate. Set to 0 to disable the cap (not recommended on public nodes).
max-search-scan-budget = {{ .RPC.MaxSearchScanBudget }}

#######################################################################
### P2P Configuration Options ###
#######################################################################
Expand Down
131 changes: 101 additions & 30 deletions sei-tendermint/internal/state/indexer/block/kv/kv.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
// such that matching search criteria returns the respective block height(s).
type BlockerIndexer struct {
store dbm.DB
// budget bounds the index entries in-flight searches may visit; nil means
// unlimited. It is typically shared with the tx indexer so the cap is
// process-wide across tx_search and block_search.
budget *indexer.ScanBudget
}

func New(store dbm.DB) *BlockerIndexer {
Expand All @@ -34,6 +38,13 @@
}
}

// WithScanBudget attaches a shared scan budget that bounds how many index
// entries in-flight searches may visit.
func (idx *BlockerIndexer) WithScanBudget(budget *indexer.ScanBudget) *BlockerIndexer {
idx.budget = budget
return idx
}

// Has returns true if the given height has been indexed. An error is returned
// upon database query failure.
func (idx *BlockerIndexer) Has(height int64) (bool, error) {
Expand Down Expand Up @@ -84,6 +95,9 @@
return results, nil
}

lease := idx.budget.Lease()
defer lease.Release()

conditions := q.Syntax()

// If there is an exact height query, return the result immediately
Expand Down Expand Up @@ -111,14 +125,14 @@
// the limit, so a broad query does not materialize and sort the full match
// set.
if plan, ok := planBounded(conditions, ranges, rangeIndexes); ok {
return idx.searchBounded(ctx, plan, opts)
return idx.searchBounded(ctx, plan, opts, lease)
}

// Fallback: queries containing CONTAINS/MATCHES/EXISTS or non-height ranges
// cannot be point-probed against a candidate height (the block's events
// live only in the index, so there is nothing cheap to fetch). Materialize
// the intersection as before, then bound and order the result set.
filteredHeights, err := idx.intersect(ctx, conditions, ranges, rangeIndexes)
filteredHeights, err := idx.intersect(ctx, conditions, ranges, rangeIndexes, lease)
if err != nil {
return nil, err
}
Expand All @@ -135,6 +149,7 @@
conditions []syntax.Condition,
ranges indexer.QueryRanges,
rangeIndexes []int,
lease *indexer.ScanLease,
) (map[string][]byte, error) {
var heightsInitialized bool
filteredHeights := make(map[string][]byte)
Expand All @@ -152,7 +167,7 @@
}

if !heightsInitialized {
filteredHeights, err = idx.matchRange(ctx, qr, prefix, filteredHeights, true)
filteredHeights, err = idx.matchRange(ctx, qr, prefix, filteredHeights, true, lease)
if err != nil {
return nil, err
}
Expand All @@ -165,7 +180,7 @@
break
}
} else {
filteredHeights, err = idx.matchRange(ctx, qr, prefix, filteredHeights, false)
filteredHeights, err = idx.matchRange(ctx, qr, prefix, filteredHeights, false, lease)
if err != nil {
return nil, err
}
Expand All @@ -185,7 +200,7 @@
}

if !heightsInitialized {
filteredHeights, err = idx.match(ctx, c, startKey, filteredHeights, true)
filteredHeights, err = idx.match(ctx, c, startKey, filteredHeights, true, lease)
if err != nil {
return nil, err
}
Expand All @@ -198,7 +213,7 @@
break
}
} else {
filteredHeights, err = idx.match(ctx, c, startKey, filteredHeights, false)
filteredHeights, err = idx.match(ctx, c, startKey, filteredHeights, false, lease)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -243,28 +258,14 @@
return results, nil
}

// searchBounded executes a boundedPlan: it scans the driver prefix in
// order_by order, point-probes the remaining conditions per candidate height,
// and stops as soon as opts.Limit matches are collected. Memory is bounded by
// the number of results kept rather than by the full match cardinality.
func (idx *BlockerIndexer) searchBounded(ctx context.Context, plan boundedPlan, opts indexer.SearchOptions) ([]int64, error) {
var (
prefix []byte
err error
)
if plan.driverEquality != nil {
prefix, err = orderedcode.Append(nil, plan.driverEquality.Tag, plan.driverEquality.Arg.Value())
} else {
// Drive off the primary block.height key range.
prefix, err = orderedcode.Append(nil, types.BlockHeightKey)
}
// searchBounded executes a boundedPlan: it scans the driver in order_by order,
// point-probes the remaining conditions per candidate height, and stops as soon
// as opts.Limit matches are collected. Memory is bounded by the number of
// results kept rather than by the full match cardinality.
func (idx *BlockerIndexer) searchBounded(ctx context.Context, plan boundedPlan, opts indexer.SearchOptions, lease *indexer.ScanLease) ([]int64, error) {
it, err := idx.driverIterator(plan, opts.OrderDesc)
if err != nil {
return nil, fmt.Errorf("failed to create driver prefix key: %w", err)
}

it, err := idx.prefixIterator(prefix, opts.OrderDesc)
if err != nil {
return nil, fmt.Errorf("failed to create driver iterator: %w", err)
return nil, err
}
defer func() { _ = it.Close() }()

Expand All @@ -276,12 +277,17 @@
break
}

// Charge each driver entry the scan walks against the shared budget.
if err := lease.Visit(1); err != nil {
Comment on lines 277 to +281

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Range-only block_search (e.g. block.height <= 100) and tx_search (e.g. tx.height >= N) can now hard-fail with ErrScanBudgetExceeded on chains larger than the 100k default budget when the scan direction is opposite to where matches live. In searchBounded/matchRange, lease.Visit(1) is charged before the range filter and there is no early break on the range bound, so on a 1M-block chain a query for block.height <= 100 with the default OrderDesc=true walks ~100k out-of-range heights before ever reaching a match and returns (nil, err) — zero results. Even 'give me recent N blocks' (e.g. block.height >= H-100) can collect its ~100 matches and then still error out because the loop keeps walking past the last match until opts.Limit (default 10k) is reached, discarding the collected results. Fix: add an early break in searchBounded/matchRange when direction+range prove no further matches are possible, or return partial results on Visit failure.

Extended reasoning...

What happens

For a range-only query like block.height <= 100 on a large chain with the default settings (MaxSearchScanBudget=100_000, MaxTxSearchResults=10_000, OrderBy=""OrderDesc=true), the bounded fast path enters searchBounded with plan.driverEquality=nil, so it drives off the entire types.BlockHeightKey prefix (block/kv/kv.go:274). Because orderedcode.Append(nil, BlockHeightKey, height) preserves int64 numeric order, the reverse iterator walks from the highest indexed height downward.

Each loop iteration calls lease.Visit(1) before candidateMatches evaluates HeightInRange (kv.go:295 vs 304). When the height is out of range candidateMatches returns false, nil and the loop just continues — no break/seek on the range bound. On a chain with 1M blocks, iterations 1..999_900 walk heights 1_000_000..101, every one failing HeightInRange but each charging Visit(1). Iteration 100_001 makes inFlight = 100_001 > 100_000; Visit returns ErrScanBudgetExceeded; searchBounded returns (nil, err) — the client receives zero results and the misleading error "narrow the query or retry later" for a query that already matches exactly 100 blocks.

The symmetric ascending case fails identically: block.height >= 999_900 with OrderDesc=false walks 1..999_899 out-of-range before reaching any match.

A subtler case affects the "recent N blocks" pattern that Sei operators/tools actually run: block.height >= 999_900 with default OrderDesc=true collects 101 matches at heights 1_000_000..999_900, but because opts.Limit=10_000 >> 101, the len(results) >= opts.Limit break is unreachable. The scan continues down past h=999_899, 999_898, ..., every iteration charging Visit(1). After ~100k such iterations, budget exhausts and searchBounded returns (nil, err) — the 101 already-collected matches are discarded.

The tx path is affected via a different code path with the same root cause: tx/kv.go:planBounded rejects range-only queries (if len(equalities) == 0 { return boundedPlan{}, false }), so tx.height >= N with no equality drops into intersect → matchRange. matchRange charges Visit(1) per iterator step (line 743) with no range-bound early-break, iterating the full tx.height composite prefix. Because that secondary index stores height as a decimal string (fmt.Sprintf("%d", height)), its key order is lexicographic — not numeric — so even a range-aware early-break would be trickier there; seeking the iterator or returning partial results on budget error is the cleaner fix on the tx side.

Why existing safeguards do not save it

  • opts.Limit breaks after collecting Limit matches; when the match set < Limit (the common case for narrow queries against a 10k default limit), the break is never reached.
  • ctx.Err() only breaks on client-side cancellation; a synchronous RPC waiting on its own reply does not cancel.
  • planBounded correctly identifies the query as bounded (finite match set), but searchBounded's iteration cost is bounded by the driver-prefix cardinality, not by the match set.

Why this is PR-material, not pre-existing

Before this PR, the same "walk the whole prefix, filter per candidate" behavior existed (from PLT-748), and the same query would have been slow-but-correct — walking N entries once and returning results. This PR wires MaxSearchScanBudget with a conservative 100_000 default and makes any Visit past that ceiling return ErrScanBudgetExceeded. That converts legitimate narrow queries whose implementation happens to be a broad scan from slow-successes into hard failures. On production Sei chains (400ms blocks, mainnet has orders of magnitude more than 100k indexed blocks) this is reachable on default config.

Addressing the "already accepted via seidroid P1" refutation

The seidroid P1 comment on tx/kv.go:344 flags a distinct concern: in the equality-driven fast path, memory is bounded by opts.Limit but the driver-entry count that consumes budget is not, so an equality query can consume budget disproportional to its memory footprint. The maintainer accepted that tradeoff for equality drivers because the driver prefix is at least scoped to the equality — some semantic relationship to the query.

The range-only failure here is functionally different in two important ways: (1) there is no equality driver, so the "driver" is the entire block.height prefix — every indexed block, unrelated to the query semantics; (2) the concern is not "fairness across concurrent queries" but a single query on an idle node failing on default config for its own sole scan. The PR description frames the budget as a backstop for "broad" fallback queries, but by this PR's own definition a block.height <= 100 query is narrow — it just has a driver implementation that walks broadly. The two conditions are addressable by the same class of fixes (early break / iterator seek / partial-result return) but they are distinct issues; the range-only case is not covered by the accepted P1 tradeoff.

Step-by-step proof (concrete)

  1. Client submits block_search with query="block.height <= 100", order_by="" on a node with 1_000_000 indexed blocks and MaxSearchScanBudget=100_000 (default).
  2. rpc/core/blocks.go:329-331: req.OrderBy = ""orderDesc = true. opts.Limit = 10_000 (default MaxTxSearchResults).
  3. block/kv/kv.go:SearchLookForRanges returns {block.height: {UpperBound:100, IncludeUpperBound:true}}, no equalities.
  4. planBounded: len(equalities)==0, len(heightRanges)==1 → falls into case len(plan.heightRanges) > 0, returns plan{driverEquality:nil, heightRanges:[<=100]}, true.
  5. searchBounded line 274: prefix = orderedcode.Append(nil, types.BlockHeightKey).
  6. prefixIterator(prefix, desc=true) → ReverseIterator starting at h=1_000_000.
  7. Loop iteration 1: Visit(1)inFlight=1. h=1_000_000. candidateMatches: HeightInRange(1_000_000, ≤100) = false → continue.
  8. Iterations 2..100_000: same pattern, each charging Visit(1) and continuing.
  9. Iteration 100_001: Visit(1)inFlight.Add(1) = 100_001 > max=100_000 → returns ErrScanBudgetExceeded.
  10. searchBounded returns (nil, ErrScanBudgetExceeded) at line 296.
  11. Client receives error "kv indexer scan budget exceeded; narrow the query or retry later" — for a query that already matches exactly 100 blocks.

Suggested fixes (any one sufficient)

  1. In searchBounded, when driving off the primary block.height range with driverEquality==nil, break the loop as soon as HeightInRange fails in a direction-consistent way (OrderDesc=true and h < lowerBound → break; OrderDesc=false and h > upperBound → break). Since orderedcode preserves int64 order on BlockHeightKey, this is safe.
  2. On Visit failure, if results is non-empty, return (results, nil) — matches the ctx.Done() semantics elsewhere in the file and preserves already-collected matches for the "recent N blocks" case.
  3. Seek the iterator to the range's inclusive bound key so out-of-range entries are skipped at the storage layer rather than walked one Visit at a time.

On the tx side, option 2 (return partial results on budget error) is the cleanest fit because the tx.height secondary index is lex-ordered and does not admit a simple numeric early-break.

🔬 also observed by seidroid

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 fix in this PR (commit ffaea3e, driverIterator) addresses the block-search half of this issue: a range-only block_search (no equality driver) now seeks the iterator to the range's inclusive [lowerBound, upperBound] bounds via heightKey(), so out-of-range heights are no longer walked/charged. The new TestBlockIndexerScanBudget test confirms this for both scan directions.

However, the symmetric tx_search issue described in this comment is still unaddressed. tx/kv/kv.go's planBounded (line ~475) still requires len(equalities) > 0, so a range-only query like tx.height >= N (no equality) still falls through to intersect -> matchRange, which charges lease.Visit(1) per iterator step with no seek/early-break and no partial-result fallback on ErrScanBudgetExceeded -- identical to the failure mode described in the original report. As noted there, the tx.height secondary index is lexicographically ordered (decimal string), so a numeric seek isn't a direct fit there; returning partial results already collected when Visit fails (matching the ctx.Done() semantics already used elsewhere in matchRange) would be the cleaner fix for that path.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Confirmed still applicable after the latest commit (eaed765, "Charging for probe visits"): that commit only adds lease.Visit charging for equality probes in tx/kv/kv.go's candidateMatches (the separate seidroid P1 nit), and does not touch planBounded's len(equalities)==0 rejection or matchRange's lack of a partial-result fallback. A range-only tx_search query (e.g. tx.height >= N with no equality) still falls through to intersect -> matchRange, which charges lease.Visit(1) per iterator step over the lexicographically-ordered tx.height secondary index with no seek/early-break and no partial-result return on ErrScanBudgetExceeded -- identical to the failure mode described in the original report. The block_search half remains fixed via driverIterator. Suggested fix (as noted previously): return the already-collected filteredHashes on Visit failure in matchRange, matching the ctx.Done() partial-result semantics already used elsewhere in that function.

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 after the latest commits (eaed765 "Charging for probe visits", 1cfe3a2 "Applied clear() to allow rest of result array be GCed"). Neither touches tx/kv/kv.go's planBounded or matchRange. planBounded (kv.go:482-485) still rejects range-only queries via if len(equalities) == 0 { return boundedPlan{}, false }, so a range-only tx_search like tx.height >= N (no equality) still falls through to intersect -> matchRange (kv.go:730+), which charges lease.Visit(1) per iterator step over the lexicographically-ordered tx.height secondary index with no seek/early-break and no partial-result return on ErrScanBudgetExceeded. This is the same failure mode described in the original report. The block_search half remains fixed via driverIterator. Suggested fix (unchanged from prior notes): return the already-collected filteredHashes on Visit failure in matchRange, matching the ctx.Done() partial-result semantics already used elsewhere in that function.

return nil, err
}

h := int64FromBytes(it.Value())
if _, dup := seen[h]; dup {
continue
}

match, err := idx.candidateMatches(h, plan)
match, err := idx.candidateMatches(h, plan, lease)
if err != nil {
return nil, err
}
Expand All @@ -304,18 +310,61 @@
return results, nil
}

// driverIterator builds the height-ordered iterator that searchBounded walks.
// An equality driver scans that condition's event-key prefix. A primary
// block.height range (no equality) is seeked to its inclusive [lower, upper]
// bounds so out-of-range heights are never walked or charged against the
// budget.
func (idx *BlockerIndexer) driverIterator(plan boundedPlan, desc bool) (dbm.Iterator, error) {
if plan.driverEquality != nil {
prefix, err := orderedcode.Append(nil, plan.driverEquality.Tag, plan.driverEquality.Arg.Value())
if err != nil {
return nil, fmt.Errorf("failed to create driver prefix key: %w", err)
}
return idx.prefixIterator(prefix, desc)

Check failure on line 324 in sei-tendermint/internal/state/indexer/block/kv/kv.go

View check run for this annotation

Claude / Claude Code Review

Equality-driven bounded scan ignores height range bounds, still vulnerable to budget exhaustion

In `driverIterator` (sei-tendermint/internal/state/indexer/block/kv/kv.go:318-351), the equality branch (`plan.driverEquality != nil`) scans only the equality's `(tag, value)` prefix and never consults `plan.heightRanges`, unlike the sibling no-equality branch which was fixed to seek to the range bounds. A query like `app.name='sei' AND block.height<=3` on a chain where `app.name=sei` is true on every block walks every equality-matching height from the scan-direction start down to height 3, char
Comment on lines +318 to +324

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 driverIterator (sei-tendermint/internal/state/indexer/block/kv/kv.go:318-351), the equality branch (plan.driverEquality != nil) scans only the equality's (tag, value) prefix and never consults plan.heightRanges, unlike the sibling no-equality branch which was fixed to seek to the range bounds. A query like app.name='sei' AND block.height<=3 on a chain where app.name=sei is true on every block walks every equality-matching height from the scan-direction start down to height 3, charging lease.Visit(1) per entry, and can exhaust the scan budget before returning legitimate matches. The same gap exists in tx/kv.go's searchBounded, which always scans the full equality-prefix range with no height-range seek.

Extended reasoning...

What's wrong: driverIterator builds the height-ordered iterator that searchBounded walks. When plan.driverEquality != nil, it returns idx.prefixIterator(prefix, desc) built solely from orderedcode.Append(nil, tag, value) — the equality condition's prefix. plan.heightRanges is populated on the boundedPlan (and used afterward by candidateMatches to filter each candidate via HeightInRange), but it is never consulted here to bound or seek the iterator itself. This is the exact same class of bug the range-seek fix in this PR (the heightKey-based seeking in the driverEquality == nil branch, lines ~327-350) addressed — but only for the no-equality path. The equality+range combination, arguably the more common real-world query shape (e.g. "give me all transfer events for block.height <= H"), falls straight through to the unbounded prefix scan.

Code path: Search -> planBounded (recognizes the query as equality + height-range eligible) -> searchBounded -> driverIterator (takes the driverEquality != nil branch, ignoring heightRanges) -> loop charges lease.Visit(1) on every driver entry before candidateMatches checks HeightInRange, and there is no early break/seek on the range bound.

Why nothing else prevents it: candidateMatches does correctly reject out-of-range heights, but only after the entry has already been walked and charged. There's no mechanism that stops the scan early or narrows it to the intersection of the equality prefix and the height range — the loop just walks the full equality-prefix range in scan-direction order until either a match count or the budget is exhausted.

Concrete step-by-step proof (using a setup like the PR's own TestBlockIndexerScanBudget):

  1. Index a chain of heights 1..N where every height carries an indexed attribute app.name=sei.
  2. Set a modest ScanBudget (e.g. the production default of 100,000, or a small test value like 10).
  3. Run the query app.name='sei' AND block.height<=3 with the default OrderDesc=true.
  4. planBounded selects app.name=sei as plan.driverEquality and records the height range <=3 in plan.heightRanges.
  5. driverIterator takes the driverEquality != nil branch and returns a ReverseIterator over the full (app.name, sei) prefix — i.e. all heights N, N-1, N-2, ... 1 — because heightRanges is never read in this branch.
  6. searchBounded's loop walks height N first: lease.Visit(1) charges it, candidateMatches checks HeightInRange(N, <=3) which is false, then continue. Same for N-1, N-2, ... down to 4 — every one of these charges the budget and is discarded.
  7. If the count of out-of-range heights walked before reaching height 3 exceeds the configured budget, lease.Visit returns ErrScanBudgetExceeded at some height above 3, and searchBounded returns (nil, err) — even though heights 1, 2, 3 are genuine matches that were never reached.
  8. On a production chain (millions of blocks, a commonly-true attribute, default MaxSearchScanBudget=100_000), an ordinary "give me all X events for block.height <= H" query with H far from the current tip fails outright with zero results and a misleading "narrow the query or retry later" error.

Distinctness from the range-only bug this PR already addresses: the PR's driverIterator fix explicitly only seeks to range bounds in the plan.driverEquality == nil branch (confirmed by reading the code directly); the driverEquality != nil branch is a separate, untouched code path with the identical failure mode. The tx-side searchBounded (tx/kv/kv.go) has the analogous gap: it always builds its prefix from prefixFromCompositeKeyAndValue(driverEquality.Tag, value) with no height-range seek, and because planBounded for tx always requires at least one equality, every tx bounded scan with a height range attached goes through this unseeked path.

Suggested fix: when plan.driverEquality != nil and plan.heightRanges is non-empty, intersect the equality-driver iterator's start/end bounds with the height-range bounds — the equality-prefix keys are height-ordered within that prefix (confirmed key layout: composite key, event value, height, type), so seeking within the (tag, value) prefix to the range bound is feasible — rather than scanning the full equality-prefix range unconditionally.

}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Equality scans charge out-of-range heights

High Severity

Height-only block scans seek to [lower, upper] so out-of-range heights are never walked or charged, but equality-driven bounded scans still walk the full event prefix and call lease.Visit before height filters run. On a chain longer than max-search-scan-budget, common historical queries with an equality plus height upper bound (default descending order) can exhaust the shared budget and return ErrScanBudgetExceeded before reaching in-range matches. Every tx fast path is equality-driven, so tx_search is especially exposed.

Additional Locations (2)
Fix in Cursor Fix in Web

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


base, err := orderedcode.Append(nil, types.BlockHeightKey)
if err != nil {
return nil, fmt.Errorf("failed to create driver prefix key: %w", err)
}

start, end := base, indexer.PrefixUpperBound(base)
qr := plan.heightRanges[0]
if lb, ok := qr.LowerBoundValue().(int64); ok {
if start, err = heightKey(lb); err != nil {
return nil, fmt.Errorf("failed to create lower-bound key: %w", err)
}
}
if ub, ok := qr.UpperBoundValue().(int64); ok {
ubKey, err := heightKey(ub)
if err != nil {
return nil, fmt.Errorf("failed to create upper-bound key: %w", err)
}
end = indexer.PrefixUpperBound(ubKey)
}

if desc {
return idx.store.ReverseIterator(start, end)
}
return idx.store.Iterator(start, end)
}

// candidateMatches reports whether the block at height h satisfies every
// non-driver condition in the plan: height-range bounds are evaluated directly
// from h, and equality probes are tested with a single point lookup against the
// event index.
func (idx *BlockerIndexer) candidateMatches(h int64, plan boundedPlan) (bool, error) {
// event index. Each probe is charged against lease.
func (idx *BlockerIndexer) candidateMatches(h int64, plan boundedPlan, lease *indexer.ScanLease) (bool, error) {
for i := range plan.heightRanges {
if !indexer.HeightInRange(h, plan.heightRanges[i]) {
return false, nil
}
}

for i := range plan.equalityProbes {
if err := lease.Visit(1); err != nil {
return false, err
}
c := plan.equalityProbes[i]
ok, err := idx.hasEvent(c.Tag, c.Arg.Value(), h)
if err != nil {
Expand Down Expand Up @@ -436,6 +485,7 @@
startKey []byte,
filteredHeights map[string][]byte,
firstRun bool,
lease *indexer.ScanLease,
) (map[string][]byte, error) {

// A previous match was attempted but resulted in no matches, so we return
Expand All @@ -456,6 +506,10 @@

iter:
for ; it.Valid(); it.Next() {
if err := lease.Visit(1); err != nil {
return nil, err
}

var (
eventValue string
err error
Expand Down Expand Up @@ -544,6 +598,7 @@
startKeyBz []byte,
filteredHeights map[string][]byte,
firstRun bool,
lease *indexer.ScanLease,
) (map[string][]byte, error) {

// A previous match was attempted but resulted in no matches, so we return
Expand All @@ -563,6 +618,10 @@
defer func() { _ = it.Close() }()

for ; it.Valid(); it.Next() {
if err := lease.Visit(1); err != nil {
return nil, err
}

tmpHeights[string(it.Value())] = it.Value()

if err := ctx.Err(); err != nil {
Expand All @@ -588,6 +647,10 @@

iterExists:
for ; it.Valid(); it.Next() {
if err := lease.Visit(1); err != nil {
return nil, err
}

tmpHeights[string(it.Value())] = it.Value()

select {
Expand Down Expand Up @@ -616,6 +679,10 @@

iterContains:
for ; it.Valid(); it.Next() {
if err := lease.Visit(1); err != nil {
return nil, err
}

eventValue, err := parseValueFromEventKey(it.Key())
if err != nil {
continue
Expand Down Expand Up @@ -650,6 +717,10 @@

iterMatches:
for ; it.Valid(); it.Next() {
if err := lease.Visit(1); err != nil {
return nil, err
}

eventValue, err := parseValueFromEventKey(it.Key())
if err != nil {
continue
Expand Down
Loading
Loading