Skip to content

feat(indexer): push result cap and ordering into KV tx search (PLT-748)#3708

Open
amir-deris wants to merge 4 commits into
mainfrom
amir/plt-786-bound-kv-tx-search
Open

feat(indexer): push result cap and ordering into KV tx search (PLT-748)#3708
amir-deris wants to merge 4 commits into
mainfrom
amir/plt-786-bound-kv-tx-search

Conversation

@amir-deris

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

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #3689 (PLT-748), which pushed the result cap and ordering down
into the KV block indexer but left the tx scan path unbounded, marked
TODO(PLT-748). TxSearch still fetched the entire match set, sorted it, and
only then applied MaxTxSearchResults at the RPC layer — so a broad tx query
materialized and sorted far more results than the caller would ever see.

This PR bounds the tx scan path the same way the block indexer is bounded, and
de-duplicates the shared bounding helpers into the indexer package so both
indexers reuse one implementation.

What changed

  • KV tx indexer (tx/kv) — bounded search:
    • Fast path (planBounded / searchBounded): eligible when every condition
      is an equality (point-probeable) or a tx.height range (evaluable from the
      candidate height), with at least one equality to drive the scan. It scans the
      driver equality's secondary-index prefix (tag, value) — which orders by
      (height, index) — in order_by order, point-probes the remaining
      equalities per candidate via store.Has(secondaryKey(...)), evaluates
      height ranges from the candidate height, dedups by hash, and stops at
      Limit. Memory is bounded by results kept, not by total match cardinality.
    • Fallback path (intersect + collectBounded): queries with
      CONTAINS/MATCHES/EXISTS, non-height ranges, or only a tx.height
      range cannot drive an in-order point-probeable scan — the tx.height
      secondary index stores the height as a decimal string, so its key order is
      not numeric. These materialize the intersection as before, then order by
      (height, index) and cap.
  • RPC TxSearch: validates order_by up front, pushes the cap and ordering
    into the indexer, and keeps the post-sort cap as a safety net for sinks that
    ignore the limit (mirrors BlockSearch). Removes the TODO(PLT-748).
  • Shared bounding helpers: hoisted BoundedCap, HeightInRange, and
    PrefixUpperBound (plus the maxBoundedPrealloc const) from block/kv into
    the indexer package as exported helpers, and updated both block/kv and
    tx/kv to use them. parseHeightIndexFromKey stays tx-local (tx keys carry an
    index component; block keys do not).

Tests

  • TestTxSearchBounded covers the fast path (equality driver, multi-equality
    probe, equality + tx.height range, tx.height equality driver) and the
    fallback path (tx.height-range-only, CONTAINS), across asc/desc and
    various limits. It also asserts a cancelled context returns partial results
    without error, and that the bounded top-N equals the first N of the same query
    run unbounded — guarding against divergence between the fast and fallback
    paths.
  • Existing TestTxSearch* suites are unchanged (behavior for the zero-value,
    unbounded opts is preserved).

Known limitation / follow-up (PLT-786)

The fallback path still fully materializes its intersection before the cap is
applied — this covers CONTAINS/MATCHES/EXISTS, non-height value ranges, and
tx.height-range-only queries. These query shapes can't drive an in-order,
point-probeable scan in the current index format: the tx.height secondary
index stores the height as a decimal string (orderedcode(TxHeightKey, "N", …)),
so its key order is lexicographic, not numeric, and there is no equality to drive
off. The RPC layer still caps the returned set, so this is a transient
memory/CPU bound (scales with match cardinality), not an unbounded result.

Bounding these internally needs a height-ordered secondary index
(orderedcode(compositeKey, height, value, index)), which is a storage-format
change (write amplification + reindex/migration on upgrade) and out of scope
here. That work is tracked in PLT-786, which also makes numeric tx.height
ranges seekable for free. This PR intentionally bounds only the equality /
tx.height-point cases that the existing index can serve in order.

@amir-deris amir-deris self-assigned this Jul 6, 2026
@amir-deris amir-deris changed the title Added query planning and bounding for tx kv indexer feat(indexer): push result cap and ordering into KV tx search (PLT-748) Jul 6, 2026
@cursor

cursor Bot commented Jul 6, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes query execution for a public RPC path (tx_search); behavior is heavily tested but fallback queries can still spike memory/CPU on huge match sets.

Overview
Broad tx queries no longer materialize and sort the full match set before MaxTxSearchResults applies. The KV tx indexer now mirrors the block indexer’s bounded scan: eligible queries stream in (height, index) order and stop at the limit during the scan.

KV tx indexer: Adds a fast path (planBounded / searchBounded) for queries made only of equalities and numeric tx.height ranges, with at least one equality to drive a secondary-index scan; remaining conditions are point-probed and results are deduped and capped in-scan. Fallback refactors intersection into intersect, then collectBounded sorts and caps (still full materialization for CONTAINS/MATCHES/EXISTS, non-height ranges, and tx.height-only ranges). Prefers tx.height equality as the scan driver when present.

RPC TxSearch: Validates order_by before search, passes Limit and OrderDesc via SearchOptions, and keeps post-sort cap as a safety net for sinks that ignore limits.

Shared helpers: BoundedCap, HeightInRange, and PrefixUpperBound move from block/kv into package indexer for both indexers.

Tests add TestTxSearchBounded and height-driver probe counting; block kv only switches to shared helpers.

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

@github-actions

github-actions Bot commented Jul 6, 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 6, 2026, 10:12 PM

@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.64516% with 30 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.39%. Comparing base (0448435) to head (275cbac).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
sei-tendermint/internal/state/indexer/tx/kv/kv.go 79.62% 11 Missing and 11 partials ⚠️
...i-tendermint/internal/state/indexer/tx/kv/utils.go 63.63% 2 Missing and 2 partials ⚠️
sei-tendermint/internal/rpc/core/tx.go 81.81% 2 Missing ⚠️
sei-tendermint/internal/state/indexer/utils.go 90.90% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3708      +/-   ##
==========================================
- Coverage   59.33%   58.39%   -0.94%     
==========================================
  Files        2274     2188      -86     
  Lines      188703   178997    -9706     
==========================================
- Hits       111966   104530    -7436     
+ Misses      66672    65191    -1481     
+ Partials    10065     9276     -789     
Flag Coverage Δ
sei-chain-pr 45.91% <80.64%> (?)
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 Δ
...i-tendermint/internal/state/indexer/block/kv/kv.go 58.68% <100.00%> (ø)
...tendermint/internal/state/indexer/block/kv/util.go 84.90% <ø> (-1.77%) ⬇️
sei-tendermint/internal/rpc/core/tx.go 71.60% <81.81%> (+1.86%) ⬆️
sei-tendermint/internal/state/indexer/utils.go 90.90% <90.90%> (ø)
...i-tendermint/internal/state/indexer/tx/kv/utils.go 75.00% <63.63%> (-25.00%) ⬇️
sei-tendermint/internal/state/indexer/tx/kv/kv.go 69.32% <79.62%> (+3.11%) ⬆️

... and 87 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 refactor that pushes the result cap and (height, index) ordering into the KV tx indexer (fast path for equality/tx.height-range queries, materialize-then-bound fallback otherwise) and hoists the shared bounding helpers into the indexer package. No blocking issues found; a few non-blocking notes, including an assessment of Codex's deprecated-key concern.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • Codex's only finding (regression on deprecated slash-delimited rows like sender/addr1/height/index for sender = 'addr1') is most likely NOT a regression: both the old match path and the new searchBounded derive the scan prefix from the same prefixFromCompositeKeyAndValue(tag, value), which is an orderedcode encoding (sender\x00\x01addr1\x00\x01). The manually-written legacy raw-byte key sender/addr1/... diverges at byte 6 (0x00 vs '/'), so it never shared the orderedcode prefix and was never visited by either the old or new iterator — the query returned empty before this PR too. The real issue is that TestTxSearchDeprecatedIndexing asserts vacuously (it ranges over results without a require.Len), so it cannot distinguish empty from non-empty and never actually exercised deprecated support. Recommend strengthening that test with an explicit length assertion to remove the ambiguity Codex flagged.
  • Cursor's second-opinion review file (cursor-review.md) was empty — that pass produced no output.
  • RPC TxSearch re-sorts and re-caps results that the KV indexer has already ordered and bounded. This is intentional and documented as a safety net for sinks that ignore the limit, so it's fine, though for the KV fast path it is redundant work.
  • Behavioral tightening (acceptable/likely an improvement): invalid order_by now errors up front before the sink loop, whereas previously the error was produced inside the per-sink sort switch.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.


height, index, err := parseHeightIndexFromKey(it.Key())
if err != nil {
continue

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] Candidates whose secondary key fails to parse are silently skipped (continue). This is the one behavioral difference from the old match, which collected every value under the driver prefix regardless of key shape. In practice all real keys are orderedcode-encoded via secondaryKey, and legacy raw/slash-delimited keys do not share the orderedcode driver prefix, so this should be unreachable for well-formed stores — but if any foreign key ever did land under a matched prefix, the fast path would drop it whereas the old path returned it. Consider at least a debug log here so silent skips are observable.

Comment thread sei-tendermint/internal/state/indexer/tx/kv/kv.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-scoped, well-tested follow-up that bounds the KV tx-search scan path (fast point-probe path + materialize-then-cap fallback) and de-duplicates shared bounding helpers into the indexer package. The logic is correct and the fast/fallback paths are consistent; no blocking issues found, only minor non-blocking notes.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • The Cursor second-opinion review (cursor-review.md) produced no output; the Codex review (codex-review.md) reported no material findings but noted it could not run the tests because the sandbox couldn't fetch the Go 1.25.6 toolchain. This review's correctness assessment is therefore based on static analysis, not a local test run.
  • Test coverage suggestion: TestTxSearchBounded's "bounded cap matches unbounded prefix" invariant compares each query against its own unbounded run, but there is no direct cross-check that the fast path and the fallback path return identical ordered results for a query where both could in principle apply. Given HeightInRange mirrors matchRange's comparisons this is low risk, but an explicit fast-vs-fallback equivalence assertion would harden against future drift between the two paths.
  • The searchBounded seen map is defensive — under the current single-driver-prefix scan each tx yields exactly one key, so duplicates cannot occur unless a tx re-emits the identical indexed attribute (deduped at the DB key level anyway). It is harmless but worth a one-line comment clarifying it guards a theoretical case rather than an observed one.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

// sort results (must be done before cap and pagination)
switch req.OrderBy {
case DescendingOrder, "":
if orderDesc {

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] Minor: the KV indexer now already returns results ordered and capped to MaxTxSearchResults, so this RPC-layer re-sort is redundant work for the KV sink. It's intentional and documented as a safety net for sinks that ignore the limit/ordering, and the data set is bounded by MaxTxSearchResults, so the cost is negligible — noting only for awareness. No change required.

@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, well-tested follow-up that bounds the KV tx-search scan path (fast point-probeable path + materialize-then-cap fallback) and de-duplicates shared bounding helpers into the indexer package. I found no correctness, security, or performance blockers; only minor non-blocking notes.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • cursor-review.md is empty — the Cursor second-opinion pass produced no output. Codex (codex-review.md) reported no material findings (it could not run go test because the sandbox lacked the Go 1.25.6 toolchain).
  • Minor behavior change in TxSearch: an invalid order_by is now rejected up front, before the sink loop, instead of only inside the KV-sink branch. This is strictly better (consistent with BlockSearch) but means a malformed order_by now errors even on nodes without a KV sink. Worth a mention only; no action needed.
  • The RPC layer still sorts results after the indexer already sorts and caps them. This is intentional (documented as a safety net for sinks that ignore the limit/ordering) and cheap since the set is already bounded — no change requested.
  • Verified the moved helpers (BoundedCap, HeightInRange, PrefixUpperBound, maxBoundedPrealloc) are byte-identical to the former block/kv locals, and block/kv/util.go no longer references the indexer package after dropping the import — no dangling import. Could not run go build/go test here (sandbox denied), so a CI green run is the final confirmation.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant