-
Notifications
You must be signed in to change notification settings - Fork 885
Add shared scan budget for KV tx_search and block_search #3747
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
be76d4c
0c63c70
de64b7e
7c7ac4f
a938a6d
6c11fb6
ffaea3e
eaed765
e4c2c19
1cfe3a2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
|
@@ -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) { | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
| } | ||
|
|
@@ -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) | ||
|
|
@@ -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 | ||
| } | ||
|
|
@@ -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 | ||
| } | ||
|
|
@@ -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 | ||
| } | ||
|
|
@@ -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 | ||
| } | ||
|
|
@@ -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() }() | ||
|
|
||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Range-only Extended reasoning...What happensFor a range-only query like Each loop iteration calls The symmetric ascending case fails identically: A subtler case affects the "recent N blocks" pattern that Sei operators/tools actually run: The tx path is affected via a different code path with the same root cause: Why existing safeguards do not save it
Why this is PR-material, not pre-existingBefore 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 Addressing the "already accepted via seidroid P1" refutationThe seidroid P1 comment on The range-only failure here is functionally different in two important ways: (1) there is no equality driver, so the "driver" is the entire Step-by-step proof (concrete)
Suggested fixes (any one sufficient)
On the tx side, option 2 (return partial results on budget error) is the cleanest fit because the 🔬 also observed by seidroid There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The fix in this PR (commit ffaea3e, 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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 | ||
| } | ||
|
|
@@ -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
|
||
|
Comment on lines
+318
to
+324
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 In Extended reasoning...What's wrong: Code path: Why nothing else prevents it: Concrete step-by-step proof (using a setup like the PR's own
Distinctness from the range-only bug this PR already addresses: the PR's Suggested fix: when |
||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Equality scans charge out-of-range heightsHigh Severity Height-only block scans seek to Additional Locations (2)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 { | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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 { | ||
|
|
@@ -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 { | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
||


There was a problem hiding this comment.
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
collectBoundedthen callsGeton every entry infilteredHashes, materializing the full transaction body and events, and only trims toopts.Limitafter 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. Asomeattr EXISTSover ~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.Limitas a hard cap onfilteredHashesbefore theGetloop so the materialization is bounded by results kept. I lean towards the second since it makes the comment true rather than softening it.