diff --git a/sei-tendermint/internal/rpc/core/tx.go b/sei-tendermint/internal/rpc/core/tx.go index 3a363f10a8..e0da1e302c 100644 --- a/sei-tendermint/internal/rpc/core/tx.go +++ b/sei-tendermint/internal/rpc/core/tx.go @@ -65,40 +65,51 @@ func (env *Environment) TxSearch(ctx context.Context, req *coretypes.RequestTxSe return nil, err } + // Validate order_by up front so we can push the ordering (and the result + // cap) down into the indexer; a broad query is then bounded at the scan + // path rather than after materializing and sorting the full match set. + var orderDesc bool + switch req.OrderBy { + case DescendingOrder, "": + orderDesc = true + + case AscendingOrder: + orderDesc = false + + default: + return nil, fmt.Errorf("expected order_by to be either `asc` or `desc` or empty: %w", coretypes.ErrInvalidRequest) + } + for _, sink := range env.EventSinks { if sink.Type() == indexer.KV { - // TODO(PLT-748): the kv tx indexer currently ignores these opts and - // the cap below still applies after sort (PLT-700). Once the tx - // scan path is bounded like the block indexer, this pushes the cap - // and ordering down to the scan. results, err := sink.SearchTxEvents(ctx, q, indexer.SearchOptions{ Limit: env.Config.MaxTxSearchResults, - OrderDesc: req.OrderBy != AscendingOrder, + OrderDesc: orderDesc, }) if err != nil { return nil, err } // sort results (must be done before cap and pagination) - switch req.OrderBy { - case DescendingOrder, "": + if orderDesc { sort.Slice(results, func(i, j int) bool { if results[i].Height == results[j].Height { return results[i].Index > results[j].Index } return results[i].Height > results[j].Height }) - case AscendingOrder: + } else { sort.Slice(results, func(i, j int) bool { if results[i].Height == results[j].Height { return results[i].Index < results[j].Index } return results[i].Height < results[j].Height }) - default: - return nil, fmt.Errorf("expected order_by to be either `asc` or `desc` or empty: %w", coretypes.ErrInvalidRequest) } + // Safety net: the kv indexer already bounds to MaxTxSearchResults, + // but keep the cap so the response stays bounded for any sink that + // ignores the limit. if max := env.Config.MaxTxSearchResults; max > 0 && len(results) > max { results = results[:max] } diff --git a/sei-tendermint/internal/state/indexer/block/kv/kv.go b/sei-tendermint/internal/state/indexer/block/kv/kv.go index d4aa80cdec..c93730c252 100644 --- a/sei-tendermint/internal/state/indexer/block/kv/kv.go +++ b/sei-tendermint/internal/state/indexer/block/kv/kv.go @@ -268,7 +268,7 @@ func (idx *BlockerIndexer) searchBounded(ctx context.Context, plan boundedPlan, } defer func() { _ = it.Close() }() - results := make([]int64, 0, boundedCap(opts.Limit)) + results := make([]int64, 0, indexer.BoundedCap(opts.Limit)) seen := make(map[int64]struct{}) for ; it.Valid(); it.Next() { @@ -310,7 +310,7 @@ func (idx *BlockerIndexer) searchBounded(ctx context.Context, plan boundedPlan, // event index. func (idx *BlockerIndexer) candidateMatches(h int64, plan boundedPlan) (bool, error) { for i := range plan.heightRanges { - if !heightInRange(h, plan.heightRanges[i]) { + if !indexer.HeightInRange(h, plan.heightRanges[i]) { return false, nil } } @@ -338,7 +338,7 @@ func (idx *BlockerIndexer) prefixIterator(prefix []byte, desc bool) (dbm.Iterato if !desc { return dbm.IteratePrefix(idx.store, prefix) } - return idx.store.ReverseIterator(prefix, prefixUpperBound(prefix)) + return idx.store.ReverseIterator(prefix, indexer.PrefixUpperBound(prefix)) } // hasEvent reports whether the block at the given height has an indexed event diff --git a/sei-tendermint/internal/state/indexer/block/kv/util.go b/sei-tendermint/internal/state/indexer/block/kv/util.go index 1a971bd9fa..3c524f36ab 100644 --- a/sei-tendermint/internal/state/indexer/block/kv/util.go +++ b/sei-tendermint/internal/state/indexer/block/kv/util.go @@ -8,58 +8,9 @@ import ( "github.com/google/orderedcode" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/pubsub/query/syntax" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/state/indexer" "github.com/sei-protocol/sei-chain/sei-tendermint/types" ) -// maxBoundedPrealloc caps how much the bounded fast path preallocates for its -// result slice, so a very large (or disabled) limit does not eagerly allocate. -const maxBoundedPrealloc = 4096 - -// boundedCap returns a sensible initial capacity for a result slice given a -// limit. A non-positive limit means "unbounded", in which case we let the -// slice grow on demand. -func boundedCap(limit int) int { - if limit <= 0 { - return 0 - } - if limit < maxBoundedPrealloc { - return limit - } - return maxBoundedPrealloc -} - -// heightInRange reports whether height h falls within the (already -// inclusivity-adjusted) bounds of a numeric block.height query range. -func heightInRange(h int64, qr indexer.QueryRange) bool { - if lower := qr.LowerBoundValue(); lower != nil { - if lb, ok := lower.(int64); ok && h < lb { - return false - } - } - if upper := qr.UpperBoundValue(); upper != nil { - if ub, ok := upper.(int64); ok && h > ub { - return false - } - } - return true -} - -// prefixUpperBound returns the exclusive end key for iterating over prefix, -// i.e. the smallest key strictly greater than every key having the prefix. -// It returns nil when prefix is empty or all bytes are 0xFF (no upper bound). -func prefixUpperBound(prefix []byte) []byte { - end := make([]byte, len(prefix)) - copy(end, prefix) - for i := len(end) - 1; i >= 0; i-- { - if end[i] != 0xFF { - end[i]++ - return end[:i+1] - } - } - return nil -} - func intInSlice(a int, list []int) bool { for _, b := range list { if b == a { diff --git a/sei-tendermint/internal/state/indexer/tx/kv/kv.go b/sei-tendermint/internal/state/indexer/tx/kv/kv.go index f22ef69d72..8db094c69a 100644 --- a/sei-tendermint/internal/state/indexer/tx/kv/kv.go +++ b/sei-tendermint/internal/state/indexer/tx/kv/kv.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "fmt" "regexp" + "sort" "strconv" "strings" @@ -134,23 +135,21 @@ func (txi *TxIndex) indexEvents(result *abci.TxResultV2, hash []byte, store dbm. // condition, it queries the DB index. One special use cases here: (1) if // "tx.hash" is found, it returns tx result for it (2) for range queries it is // better for the client to provide both lower and upper bounds, so we are not -// performing a full scan. Results from querying indexes are then intersected -// and returned to the caller, in no particular order. +// performing a full scan. +// +// opts bounds and orders the result set (see indexer.SearchOptions): when a +// query is eligible for the bounded fast path it is streamed in (height, index) +// order_by order and capped at opts.Limit during the scan, so a broad query +// does not materialize and sort the full match set. Otherwise the intersection +// is materialized as before, then ordered and capped. // // Search will exit early and return any result fetched so far, // when a message is received on the context chan. -// TODO(PLT-748): push opts.Limit/OrderDesc down into the scan path here func (txi *TxIndex) Search(ctx context.Context, q *query.Query, opts indexer.SearchOptions) ([]*abci.TxResultV2, error) { - select { - case <-ctx.Done(): + if ctx.Err() != nil { return make([]*abci.TxResultV2, 0), nil - - default: } - var hashesInitialized bool - filteredHashes := make(map[string][]byte) - // get a list of conditions (like "tx.height > 5") conditions := q.Syntax() @@ -170,13 +169,46 @@ func (txi *TxIndex) Search(ctx context.Context, q *query.Query, opts indexer.Sea } } - // conditions to skip because they're handled before "everything else" - skipIndexes := make([]int, 0) - // extract ranges // if both upper and lower bounds exist, it's better to get them in order not // no iterate over kvs that are not within range. ranges, rangeIndexes := indexer.LookForRanges(conditions) + + // Fast path: when every condition is an equality (point-probeable) or a + // tx.height range (evaluable from the candidate height), drive a single + // (height, index)-ordered scan in order_by order, point-probe the remaining + // conditions per candidate, and stop at opts.Limit. Memory is bounded by the + // results kept, not by the total match cardinality. + if plan, ok := planBounded(conditions, ranges, rangeIndexes); ok { + return txi.searchBounded(ctx, plan, opts) + } + + // Fallback: queries containing CONTAINS/MATCHES/EXISTS, non-height ranges, or + // only a tx.height range cannot be driven by 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). Materialize the intersection as before, + // then bound and order the result set. + filteredHashes := txi.intersect(ctx, conditions, ranges, rangeIndexes) + return txi.collectBounded(ctx, filteredHashes, opts) +} + +// intersect returns the set of tx hashes that satisfy every condition (implicit +// AND). It seeds the set from the first condition's index matches, then +// intersects each remaining condition against it, so a tx survives only if it +// matches all of them. The returned map is keyed by tx hash string with the +// hash bytes as the value. +func (txi *TxIndex) intersect( + ctx context.Context, + conditions []syntax.Condition, + ranges indexer.QueryRanges, + rangeIndexes []int, +) map[string][]byte { + var hashesInitialized bool + filteredHashes := make(map[string][]byte) + + // conditions to skip because they're handled before "everything else" + skipIndexes := make([]int, 0) + if len(ranges) > 0 { skipIndexes = append(skipIndexes, rangeIndexes...) @@ -188,7 +220,7 @@ func (txi *TxIndex) Search(ctx context.Context, q *query.Query, opts indexer.Sea // Ignore any remaining conditions if the first condition resulted // in no matches (assuming implicit AND operand). if len(filteredHashes) == 0 { - break + return filteredHashes } } else { filteredHashes = txi.matchRange(ctx, qr, prefixFromCompositeKey(qr.Key), filteredHashes, false) @@ -212,15 +244,22 @@ func (txi *TxIndex) Search(ctx context.Context, q *query.Query, opts indexer.Sea // Ignore any remaining conditions if the first condition resulted // in no matches (assuming implicit AND operand). if len(filteredHashes) == 0 { - break + return filteredHashes } } else { filteredHashes = txi.match(ctx, c, prefixForCondition(c, height), filteredHashes, false) } } + return filteredHashes +} + +// collectBounded materializes filteredHashes into tx results, orders them by +// (height, index) per opts.OrderDesc and truncates to opts.Limit. The +// intermediate match set is still fully materialized by intersect; only the +// returned slice and the sort cost are bounded here. +func (txi *TxIndex) collectBounded(ctx context.Context, filteredHashes map[string][]byte, opts indexer.SearchOptions) ([]*abci.TxResultV2, error) { results := make([]*abci.TxResultV2, 0, len(filteredHashes)) -hashes: for _, h := range filteredHashes { res, err := txi.Get(h) if err != nil { @@ -231,16 +270,216 @@ hashes: } // Potentially exit early. - select { - case <-ctx.Done(): - break hashes - default: + if ctx.Err() != nil { + break + } + } + + sortResults(results, opts.OrderDesc) + + if opts.Limit > 0 && len(results) > opts.Limit { + results = results[:opts.Limit] + } + + return results, nil +} + +// searchBounded executes a boundedPlan: it scans the driver equality's +// secondary-index prefix (which orders by (height, index)) in order_by order, +// point-probes the remaining conditions per candidate, 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 (txi *TxIndex) searchBounded(ctx context.Context, plan boundedPlan, opts indexer.SearchOptions) ([]*abci.TxResultV2, error) { + prefix := prefixFromCompositeKeyAndValue(plan.driverEquality.Tag, plan.driverEquality.Arg.Value()) + + it, err := txi.prefixIterator(prefix, opts.OrderDesc) + if err != nil { + return nil, fmt.Errorf("failed to create driver iterator: %w", err) + } + defer func() { _ = it.Close() }() + + results := make([]*abci.TxResultV2, 0, indexer.BoundedCap(opts.Limit)) + seen := make(map[string]struct{}) + + for ; it.Valid(); it.Next() { + if ctx.Err() != nil { + break + } + + hash := it.Value() + if _, dup := seen[string(hash)]; dup { + continue + } + + height, index, err := parseHeightIndexFromKey(it.Key()) + if err != nil { + continue + } + + match, err := txi.candidateMatches(height, index, plan) + if err != nil { + return nil, err + } + if !match { + continue + } + + res, err := txi.Get(hash) + if err != nil { + return nil, fmt.Errorf("failed to get Tx{%X}: %w", hash, err) + } + if res == nil { + continue + } + + seen[string(hash)] = struct{}{} + results = append(results, res) + + if opts.Limit > 0 && len(results) >= opts.Limit { + break } } + if err := it.Error(); err != nil { + return nil, err + } + return results, nil } +// candidateMatches reports whether the tx at (height, index) satisfies every +// non-driver condition in the plan: tx.height range bounds are evaluated +// directly from height, and equality probes are tested with a single point +// lookup against the event index. +func (txi *TxIndex) candidateMatches(height int64, index uint32, plan boundedPlan) (bool, error) { + for i := range plan.heightRanges { + if !indexer.HeightInRange(height, plan.heightRanges[i]) { + return false, nil + } + } + + for i := range plan.equalityProbes { + c := plan.equalityProbes[i] + ok, err := txi.store.Has(secondaryKey(c.Tag, c.Arg.Value(), height, index)) + if err != nil { + return false, err + } + if !ok { + return false, nil + } + } + + return true, nil +} + +// prefixIterator returns an iterator over the given key prefix. When desc is +// true it iterates in descending ((height, index) most-recent-first) order. +func (txi *TxIndex) prefixIterator(prefix []byte, desc bool) (dbm.Iterator, error) { + if !desc { + return dbm.IteratePrefix(txi.store, prefix) + } + return txi.store.ReverseIterator(prefix, indexer.PrefixUpperBound(prefix)) +} + +// boundedPlan describes a fast-path execution that bounds memory by driving a +// single (height, index)-ordered scan off one equality condition and +// point-probing the remaining conditions, rather than materializing and sorting +// the full match set. +type boundedPlan struct { + // driverEquality is the equality condition whose secondary-index prefix + // (tag, value) is scanned in (height, index) order. + driverEquality *syntax.Condition + // equalityProbes are the remaining equality conditions, tested per candidate + // with a point lookup. + equalityProbes []syntax.Condition + // heightRanges are tx.height bounds evaluated directly from a candidate + // height. + heightRanges []indexer.QueryRange +} + +// planBounded decides whether a query is eligible for the bounded fast path and +// builds its plan. A query qualifies only when every condition is either an +// equality (point-probeable) or a tx.height range (evaluable from the candidate +// height), and there is at least one equality to drive an ordered scan. +// +// A tx.height-range-only query does not qualify: the tx.height secondary index +// stores the height as a decimal string, so its key order is not numeric and it +// cannot drive an in-order scan. +func planBounded(conditions []syntax.Condition, ranges indexer.QueryRanges, rangeIndexes []int) (boundedPlan, bool) { + var plan boundedPlan + + // Every range must be a numeric tx.height range; any other range needs the + // attribute's value, which cannot be derived from the height alone. + for key, qr := range ranges { + if key != types.TxHeightKey { + return boundedPlan{}, false + } + if _, ok := qr.AnyBound().(int64); !ok { + return boundedPlan{}, false + } + plan.heightRanges = append(plan.heightRanges, qr) + } + + // Every non-range condition must be an equality to be point-probeable. + var equalities []syntax.Condition + for i, c := range conditions { + if intInSlice(i, rangeIndexes) { + continue + } + if c.Op != syntax.TEq { + return boundedPlan{}, false + } + equalities = append(equalities, c) + } + + // Need at least one equality to drive an ordered scan. + if len(equalities) == 0 { + return boundedPlan{}, false + } + + // Prefer a tx.height equality when one is present: + // its (TxHeightKey, "N") prefix is partitioned to a single height, so the + // scan visits only the txs in block N. + driver := 0 + for i := range equalities { + if equalities[i].Tag == types.TxHeightKey { + driver = i + break + } + } + plan.driverEquality = &equalities[driver] + plan.equalityProbes = make([]syntax.Condition, 0, len(equalities)-1) + for i := range equalities { + if i == driver { + continue + } + plan.equalityProbes = append(plan.equalityProbes, equalities[i]) + } + + return plan, true +} + +// sortResults orders tx results by (height, index): descending (most recent +// first) when desc is true, ascending otherwise. It matches the ordering the +// RPC layer applies after the search. +func sortResults(results []*abci.TxResultV2, desc bool) { + if desc { + sort.Slice(results, func(i, j int) bool { + if results[i].Height == results[j].Height { + return results[i].Index > results[j].Index + } + return results[i].Height > results[j].Height + }) + } else { + sort.Slice(results, func(i, j int) bool { + if results[i].Height == results[j].Height { + return results[i].Index < results[j].Index + } + return results[i].Height < results[j].Height + }) + } +} + func lookForHash(conditions []syntax.Condition) (hash []byte, ok bool, err error) { for _, c := range conditions { if c.Tag == types.TxHashKey { diff --git a/sei-tendermint/internal/state/indexer/tx/kv/kv_test.go b/sei-tendermint/internal/state/indexer/tx/kv/kv_test.go index 509e3d663e..8c15a13a19 100644 --- a/sei-tendermint/internal/state/indexer/tx/kv/kv_test.go +++ b/sei-tendermint/internal/state/indexer/tx/kv/kv_test.go @@ -324,6 +324,204 @@ func TestTxSearchMultipleTxs(t *testing.T) { require.Len(t, results, 3) } +func TestTxSearchBounded(t *testing.T) { + idx := NewTxIndex(dbm.NewMemDB()) + + // Two txs per height for heights 1..5. Every tx carries app.name=sei; the + // index-0 tx of each height also carries app.kind=even. Tx bytes are unique + // per (height, index) so each has a distinct hash. + for h := int64(1); h <= 5; h++ { + for i := uint32(0); i < 2; i++ { + events := []abci.Event{{ + Type: "app", + Attributes: []abci.EventAttribute{{Key: []byte("name"), Value: []byte("sei"), Index: true}}, + }} + if i == 0 { + events = append(events, abci.Event{ + Type: "app", + Attributes: []abci.EventAttribute{{Key: []byte("kind"), Value: []byte("even"), Index: true}}, + }) + } + res := &abci.TxResultV2{ + Height: h, + Index: i, + Tx: types.Tx(fmt.Sprintf("tx-%d-%d", h, i)), + Result: abci.ExecTxResult{Code: abci.CodeTypeOK, Events: events}, + } + require.NoError(t, idx.Index([]*abci.TxResultV2{res})) + } + } + + // hi is a (height, index) pair used to assert result identity/order. + type hi struct { + h int64 + i uint32 + } + pairs := func(results []*abci.TxResultV2) []hi { + out := make([]hi, len(results)) + for k, r := range results { + out[k] = hi{r.Height, r.Index} + } + return out + } + + testCases := map[string]struct { + q string + opts indexer.SearchOptions + want []hi + }{ + // Fast path: single equality driver, scanned in order_by order and capped + // at the scan rather than after a full sort. + "equality desc limit 3": { + q: `app.name = 'sei'`, + opts: indexer.SearchOptions{Limit: 3, OrderDesc: true}, + want: []hi{{5, 1}, {5, 0}, {4, 1}}, + }, + "equality asc limit 3": { + q: `app.name = 'sei'`, + opts: indexer.SearchOptions{Limit: 3, OrderDesc: false}, + want: []hi{{1, 0}, {1, 1}, {2, 0}}, + }, + // A disabled cap (Limit <= 0) returns the full set in order_by order. + "equality desc unbounded": { + q: `app.name = 'sei'`, + opts: indexer.SearchOptions{Limit: 0, OrderDesc: true}, + want: []hi{{5, 1}, {5, 0}, {4, 1}, {4, 0}, {3, 1}, {3, 0}, {2, 1}, {2, 0}, {1, 1}, {1, 0}}, + }, + // Fast path: two equalities — driver plus a point-probe. app.kind=even + // only matches the index-0 tx of each height. + "two equalities desc limit 2": { + q: `app.name = 'sei' AND app.kind = 'even'`, + opts: indexer.SearchOptions{Limit: 2, OrderDesc: true}, + want: []hi{{5, 0}, {4, 0}}, + }, + // Fast path: equality driver with a tx.height range filter. + "equality and height range desc limit 2": { + q: `app.name = 'sei' AND tx.height >= 4`, + opts: indexer.SearchOptions{Limit: 2, OrderDesc: true}, + want: []hi{{5, 1}, {5, 0}}, + }, + // Fast path: a tx.height equality drives its own (height, index)-ordered + // prefix. + "tx.height equality desc": { + q: `tx.height = 3`, + opts: indexer.SearchOptions{Limit: 5, OrderDesc: true}, + want: []hi{{3, 1}, {3, 0}}, + }, + // Fallback path: a tx.height-range-only query has no equality to drive an + // in-order scan (the height is stored as a decimal string), so it is + // materialized, then ordered and capped. + "height range only desc limit 3": { + q: `tx.height >= 4`, + opts: indexer.SearchOptions{Limit: 3, OrderDesc: true}, + want: []hi{{5, 1}, {5, 0}, {4, 1}}, + }, + // Fallback path: CONTAINS cannot be point-probed, but the result set is + // still ordered and capped. + "contains fallback desc limit 3": { + q: `app.name CONTAINS 'se'`, + opts: indexer.SearchOptions{Limit: 3, OrderDesc: true}, + want: []hi{{5, 1}, {5, 0}, {4, 1}}, + }, + "contains fallback asc limit 2": { + q: `app.name CONTAINS 'se'`, + opts: indexer.SearchOptions{Limit: 2, OrderDesc: false}, + want: []hi{{1, 0}, {1, 1}}, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + results, err := idx.Search(t.Context(), query.MustCompile(tc.q), tc.opts) + require.NoError(t, err) + require.Equal(t, tc.want, pairs(results)) + }) + } + + // A cancelled context makes the bounded scan return the results gathered so + // far without an error, rather than failing the whole query. + t.Run("cancelled context returns partial results", func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + results, err := idx.Search(ctx, query.MustCompile(`app.name = 'sei'`), indexer.SearchOptions{Limit: 5, OrderDesc: true}) + require.NoError(t, err) + require.Empty(t, results) + }) + + // Capping in the scan must not diverge from materialize-then-cap: for any + // query the bounded top-N equals the first N of the same query run + // unbounded. This covers both the fast path (equalities/height ranges) and + // the fallback (CONTAINS), guarding against future divergence between them. + t.Run("bounded cap matches unbounded prefix", func(t *testing.T) { + queries := []string{ + `app.name = 'sei'`, // fast path: equality driver + `app.name = 'sei' AND app.kind = 'even'`, // fast path: equality + probe + `app.name = 'sei' AND tx.height >= 3`, // fast path: equality + height range + `tx.height >= 3`, // fallback: height-range-only + `app.name CONTAINS 'se'`, // fallback: materialize then bound + } + for _, q := range queries { + for _, desc := range []bool{true, false} { + full, err := idx.Search(t.Context(), query.MustCompile(q), indexer.SearchOptions{Limit: 0, OrderDesc: desc}) + require.NoError(t, err) + for n := 1; n <= len(full); n++ { + capped, err := idx.Search(t.Context(), query.MustCompile(q), indexer.SearchOptions{Limit: n, OrderDesc: desc}) + require.NoError(t, err) + require.Equalf(t, pairs(full[:n]), pairs(capped), "query %q desc=%v limit=%d", q, desc, n) + } + } + } + }) +} + +// countingDB wraps a dbm.DB and counts point lookups (Has) so a test can assert +// that a bounded scan point-probes only the candidates in its driver prefix +// rather than every row in the full match set. +type countingDB struct { + dbm.DB + hasCount int +} + +func (c *countingDB) Has(key []byte) (bool, error) { + c.hasCount++ + return c.DB.Has(key) +} + +func TestTxSearchBoundedPrefersHeightDriver(t *testing.T) { + store := &countingDB{DB: dbm.NewMemDB()} + idx := NewTxIndex(store) + + // One tx per height for heights 1..20, each carrying sender.addr=addr1. + // Driving off sender scans all 20 heights; driving off tx.height=N scans + // only block N. + const heights = 20 + for h := int64(1); h <= heights; h++ { + res := &abci.TxResultV2{ + Height: h, + Index: 0, + Tx: types.Tx(fmt.Sprintf("tx-%d", h)), + Result: abci.ExecTxResult{Code: abci.CodeTypeOK, Events: []abci.Event{{ + Type: "sender", + Attributes: []abci.EventAttribute{{Key: []byte("addr"), Value: []byte("addr1"), Index: true}}, + }}}, + } + require.NoError(t, idx.Index([]*abci.TxResultV2{res})) + } + + store.hasCount = 0 + results, err := idx.Search( + t.Context(), + query.MustCompile(`sender.addr = 'addr1' AND tx.height = 7`), + indexer.SearchOptions{Limit: 10, OrderDesc: true}, + ) + require.NoError(t, err) + + // Correctness: only the height-7 tx matches. + require.Len(t, results, 1) + require.Equal(t, int64(7), results[0].Height) + require.Equal(t, 1, store.hasCount, "bounded scan probed %d times; must probe only block-7 candidates, not every sender.addr row", store.hasCount) +} + func txResultWithEvents(events []abci.Event) *abci.TxResultV2 { tx := types.Tx("HELLO WORLD") return &abci.TxResultV2{ diff --git a/sei-tendermint/internal/state/indexer/tx/kv/utils.go b/sei-tendermint/internal/state/indexer/tx/kv/utils.go index 48362bfbc2..7436ad1224 100644 --- a/sei-tendermint/internal/state/indexer/tx/kv/utils.go +++ b/sei-tendermint/internal/state/indexer/tx/kv/utils.go @@ -1,6 +1,12 @@ package kv -// IntInSlice returns true if a is found in the list. +import ( + "fmt" + + "github.com/google/orderedcode" +) + +// intInSlice returns true if a is found in the list. func intInSlice(a int, list []int) bool { for _, b := range list { if b == a { @@ -9,3 +15,23 @@ func intInSlice(a int, list []int) bool { } return false } + +// parseHeightIndexFromKey extracts the (height, index) suffix of a secondary +// (event) key. The tx index encodes each event key as +// orderedcode(compositeKey, value, height, int64(index)). +func parseHeightIndexFromKey(key []byte) (int64, uint32, error) { + var ( + compositeKey, value string + height, index int64 + ) + + remaining, err := orderedcode.Parse(string(key), &compositeKey, &value, &height, &index) + if err != nil { + return 0, 0, fmt.Errorf("failed to parse event key: %w", err) + } + if len(remaining) != 0 { + return 0, 0, fmt.Errorf("unexpected remainder in key: %s", remaining) + } + + return height, uint32(index), nil //nolint:gosec // index is stored as int64(uint32) by secondaryKey, so the round-trip back to uint32 cannot overflow +} diff --git a/sei-tendermint/internal/state/indexer/utils.go b/sei-tendermint/internal/state/indexer/utils.go new file mode 100644 index 0000000000..39b6a966e6 --- /dev/null +++ b/sei-tendermint/internal/state/indexer/utils.go @@ -0,0 +1,50 @@ +package indexer + +// maxBoundedPrealloc caps how much a bounded search fast path preallocates for +// its result slice, so a very large (or disabled) limit does not eagerly +// allocate. +const maxBoundedPrealloc = 4096 + +// BoundedCap returns a sensible initial capacity for a result slice given a +// limit. A non-positive limit means "unbounded", in which case we let the +// slice grow on demand. +func BoundedCap(limit int) int { + if limit <= 0 { + return 0 + } + if limit < maxBoundedPrealloc { + return limit + } + return maxBoundedPrealloc +} + +// HeightInRange reports whether height h falls within the (already +// inclusivity-adjusted) bounds of a numeric height query range. +func HeightInRange(h int64, qr QueryRange) bool { + if lower := qr.LowerBoundValue(); lower != nil { + if lb, ok := lower.(int64); ok && h < lb { + return false + } + } + if upper := qr.UpperBoundValue(); upper != nil { + if ub, ok := upper.(int64); ok && h > ub { + return false + } + } + return true +} + +// PrefixUpperBound returns the exclusive end key for iterating over prefix, +// i.e. the smallest key strictly greater than every key having the prefix. +// It returns nil when prefix is empty or all bytes are 0xFF (no upper bound). +func PrefixUpperBound(prefix []byte) []byte { + end := make([]byte, len(prefix)) + copy(end, prefix) + for i := len(end) - 1; i >= 0; i-- { + if end[i] != 0xFF { + end[i]++ + return end[:i+1] + } + } + return nil +}