diff --git a/docs/app-dev/indexing-transactions.md b/docs/app-dev/indexing-transactions.md index 40891daf23..f260ca444e 100644 --- a/docs/app-dev/indexing-transactions.md +++ b/docs/app-dev/indexing-transactions.md @@ -4,7 +4,7 @@ order: 6 # Indexing Transactions -Tendermint allows you to index transactions and blocks and later query or +CometBFT allows you to index transactions and blocks and later query or subscribe to their results. Transactions are indexed by `TxResult.Events` and blocks are indexed by `Response(Begin|End)Block.Events`. However, transactions are also indexed by a primary key which includes the transaction hash and maps @@ -15,7 +15,7 @@ the block itself is never stored. Each event contains a type and a list of attributes, which are key-value pairs denoting something about what happened during the method's execution. For more details on `Events`, see the -[ABCI](https://github.com/tendermint/tendermint/blob/v0.34.x/spec/abci/abci.md#events) +[ABCI](https://github.com/cometbft/cometbft/blob/v0.34.x/spec/abci/abci.md#events) documentation. An `Event` has a composite key associated with it. A `compositeKey` is @@ -31,10 +31,10 @@ For example: would be equal to the composite key of `jack.account.number`. -By default, Tendermint will index all transactions by their respective hashes +By default, CometBFT will index all transactions by their respective hashes and height and blocks by their height. -Tendermint allows for different events within the same height to have +CometBFT allows for different events within the same height to have equal attributes. ## Configuration @@ -65,8 +65,8 @@ be turned off regardless of other values provided. #### KV The `kv` indexer type is an embedded key-value store supported by the main -underlying Tendermint database. Using the `kv` indexer type allows you to query -for block and transaction events directly against Tendermint's RPC. However, the +underlying CometBFT database. Using the `kv` indexer type allows you to query +for block and transaction events directly against CometBFT's RPC. However, the query syntax is limited and so this indexer type might be deprecated or removed entirely in the future. @@ -103,12 +103,12 @@ will be represented as follows in the store: ``` Key value transferSenderBobEndBlock1 1 -transferRecepientAliceEndBlock11 1 +transferRecipientAliceEndBlock11 1 transferBalance100EndBlock11 1 transferNodeNothingEndblock11 1 ---- event2 ------ transferSenderTomEndBlock12 1 -transferRecepientAliceEndBlock12 1 +transferRecipientAliceEndBlock12 1 transferBalance200EndBlock12 1 transferNodeNothingEndblock12 1 @@ -127,11 +127,11 @@ indexing by proxying it to an external PostgreSQL instance allowing for the even to be stored in relational models. Since the events are stored in a RDBMS, operators can leverage SQL to perform a series of rich and complex queries that are not supported by the `kv` indexer type. Since operators can leverage SQL directly, -searching is not enabled for the `psql` indexer type via Tendermint's RPC -- any +searching is not enabled for the `psql` indexer type via CometBFT's RPC -- any such query will fail. Note, the SQL schema is stored in `state/indexer/sink/psql/schema.sql` and operators -must explicitly create the relations prior to starting Tendermint and enabling +must explicitly create the relations prior to starting CometBFT and enabling the `psql` indexer type. Example: @@ -142,7 +142,7 @@ $ psql ... -f state/indexer/sink/psql/schema.sql ## Default Indexes -The Tendermint tx and block event indexer indexes a few select reserved events +The CometBFT tx and block event indexer indexes a few select reserved events by default. ### Transactions @@ -160,7 +160,7 @@ The following indexes are indexed by default: ## Adding Events -Applications are free to define which events to index. Tendermint does not +Applications are free to define which events to index. CometBFT does not expose functionality to define which events to index and which to ignore. In your application's `DeliverTx` method, add the `Events` field with pairs of UTF-8 encoded strings (e.g. "transfer.sender": "Bob", "transfer.recipient": @@ -199,10 +199,10 @@ You can query for a paginated set of transaction by their events by calling the curl "localhost:26657/tx_search?query=\"message.sender='cosmos1...'\"&prove=true" ``` If the conditions are related to transaction events and the user wants to make sure the -conditions are true within the same events, the `match.event` keyword should be used, +conditions are true within the same events, the `match_events` keyword should be used, as described [below](#querying_block_events) -Check out [API docs](https://docs.tendermint.com/v0.34/rpc/#/Info/tx_search) +Check out [API docs](https://docs.cometbft.com/v0.34/rpc/#/Info/tx_search) for more information on query syntax and other options. ## Subscribing to Transactions @@ -221,7 +221,7 @@ a query to `/subscribe` RPC endpoint. } ``` -Check out [API docs](https://docs.tendermint.com/v0.34/rpc/#subscribe) for more information +Check out [API docs](https://docs.cometbft.com/v0.34/rpc/#subscribe) for more information on query syntax and other options. ## Querying Block Events @@ -252,11 +252,18 @@ the query syntax is as follows: ```bash curl "localhost:26657/block_search?query=\"sender=Bob AND balance = 200\"&match_events=true" ``` -Currently the default behaviour is if `match_events` is set to false. +Currently the default behavior is if `match_events` is set to false. -Check out [API docs](https://docs.tendermint.com/v0.34/rpc/#/Info/block_search) +Check out [API docs](https://docs.cometbft.com/v0.34/rpc/#/Info/block_search) for more information on query syntax and other options. **Backwards compatibility** -Up until Tendermint 0.34.25, the event sequence was not stored in the kvstore and the `match_events` keyword in the RPC query is not ignored by older versions. Thus, in a network running mixed Tendermint versions, nodes running older versions will still return blocks (or transactions) whose attributes match within different events on the same height. \ No newline at end of file +Storing the event sequence was introduced in CometBFT 0.34.25. As there are no previous releases of CometBFT, +all nodes running CometBFT will include the event sequence. However, mixed networks running CometBFT v0.34.25 and greater +and Tendermint Core versions before v0.34.25 are possible. On nodes running Tendermint Core, the `match_events` keyword +is ignored and the data is retrieved as if `match_events=false`. + +Additionally, if a node that was running Tendermint Core +when the data was first indexed, and switched to CometBFT, is queried, it will retrieve this previously indexed +data as if `match_events=false` (attributes can match the query conditions across different events on the same height). \ No newline at end of file diff --git a/light/proxy/routes.go b/light/proxy/routes.go index 3dcbad4378..e49a04e150 100644 --- a/light/proxy/routes.go +++ b/light/proxy/routes.go @@ -161,6 +161,8 @@ func makeTxSearchFuncMatchEvents(c *lrpc.Client) rpcTxSearchFuncMatchEvents { ) (*ctypes.ResultTxSearch, error) { if matchEvents { query = "match.events = 1 AND " + query + } else { + query = "match.events = 0 AND " + query } return c.TxSearch(ctx.Context(), query, prove, page, perPage, orderBy) } @@ -186,6 +188,8 @@ func makeBlockSearchFuncMatchEvents(c *lrpc.Client) rpcBlockSearchFuncMatchEvent ) (*ctypes.ResultBlockSearch, error) { if matchEvents { query = "match.events = 1 AND " + query + } else { + query = "match.events = 0 AND " + query } return c.BlockSearch(ctx.Context(), query, page, perPage, orderBy) } diff --git a/rpc/core/blocks.go b/rpc/core/blocks.go index 090395c780..8b3820a3a6 100644 --- a/rpc/core/blocks.go +++ b/rpc/core/blocks.go @@ -169,6 +169,8 @@ func BlockSearchMatchEvents( ) (*ctypes.ResultBlockSearch, error) { if matchEvents { query = "match.events = 1 AND " + query + } else { + query = "match.events = 0 AND " + query } return BlockSearch(ctx, query, pagePtr, perPagePtr, orderBy) } diff --git a/rpc/core/tx.go b/rpc/core/tx.go index 2dbdd75aa4..1ce66b27e4 100644 --- a/rpc/core/tx.go +++ b/rpc/core/tx.go @@ -149,6 +149,8 @@ func TxSearchMatchEvents( if matchEvents { query = "match.events = 1 AND " + query + } else { + query = "match.events = 0 AND " + query } return TxSearch(ctx, query, prove, pagePtr, perPagePtr, orderBy) diff --git a/state/indexer/block/kv/kv.go b/state/indexer/block/kv/kv.go index 2fabdafb31..87faa6f788 100644 --- a/state/indexer/block/kv/kv.go +++ b/state/indexer/block/kv/kv.go @@ -113,40 +113,45 @@ func (idx *BlockerIndexer) Search(ctx context.Context, q *query.Query) ([]int64, if matchEvents { matchEventIdx = 0 - } else { - matchEventIdx = -1 - } - - if matchEventIdx != -1 { skipIndexes = append(skipIndexes, matchEventIdx) } + // If there is an exact height query, return the result immediately // (if it exists). - var height int64 var ok bool - var heightIdx int + var heightInfo HeightInfo if matchEvents { // If we are not matching events and block.height = 3 occurs more than once, the later value will - // overwrite the first one. For match.events it will create problems. - conditions, height, ok, heightIdx = dedupHeight(conditions) + // overwrite the first one. For match.events it will create problems. If we have a height range, we + // should ignore height equality. + conditions, heightInfo, ok = dedupHeight(conditions) } else { - height, ok, heightIdx = lookForHeight(conditions) + heightInfo.height, ok, heightInfo.heightEqIdx = lookForHeight(conditions) } + // Extract ranges. If both upper and lower bounds exist, it's better to get + // them in order as to not iterate over kvs that are not within range. + // If we have a query range over height and want to still look for + // specific event values we do not want to simply return all + // blocks in this height range. We remember the height range info + // and pass it on to match() to take into account when processing events. + ranges, rangeIndexes, heightRange := indexer.LookForRangesWithHeight(conditions) + heightInfo.heightRange = heightRange + // If we have additional constraints and want to query per event // attributes, we cannot simply return all blocks for a height. // But we remember the height we want to find and forward it to // match(). If we only have the height constraint and match.events keyword // in the query (the second part of the ||), we don't need to query // per event conditions and return all events within the height range. - if ok && (!matchEvents || (matchEvents && len(conditions) == 2)) { - ok, err := idx.Has(height) + if ok && (!matchEvents || (matchEvents && heightInfo.onlyHeightEq)) { + ok, err := idx.Has(heightInfo.height) if err != nil { return nil, err } if ok { - return []int64{height}, nil + return []int64{heightInfo.height}, nil } return results, nil @@ -154,31 +159,21 @@ func (idx *BlockerIndexer) Search(ctx context.Context, q *query.Query) ([]int64, var heightsInitialized bool filteredHeights := make(map[string][]byte) - if matchEvents && heightIdx != -1 { - skipIndexes = append(skipIndexes, heightIdx) + if matchEvents && heightInfo.heightEqIdx != -1 { + skipIndexes = append(skipIndexes, heightInfo.heightEqIdx) } - // Extract ranges. If both upper and lower bounds exist, it's better to get - // them in order as to not iterate over kvs that are not within range. - ranges, rangeIndexes := indexer.LookForRanges(conditions) - var heightRanges indexer.QueryRange if len(ranges) > 0 { skipIndexes = append(skipIndexes, rangeIndexes...) for _, qr := range ranges { - // If we have a query range over height and want to still look for - // specific event values we do not want to simply return all - // blocks in this height range. We remember the height range info - // and pass it on to match() to take into account when processing events. - if qr.Key == types.BlockHeightKey && matchEvents { - heightRanges = qr + if qr.Key == types.BlockHeightKey && matchEvents && !heightInfo.onlyHeightRange { // If the query contains ranges other than the height then we need to treat the height // range when querying the conditions of the other range. // Otherwise we can just return all the blocks within the height range (as there is no // additional constraint on events) - if len(ranges)+1 != 2 { - continue - } + continue + } prefix, err := orderedcode.Append(nil, qr.Key) if err != nil { @@ -186,7 +181,7 @@ func (idx *BlockerIndexer) Search(ctx context.Context, q *query.Query) ([]int64, } if !heightsInitialized { - filteredHeights, err = idx.matchRange(ctx, qr, prefix, filteredHeights, true, matchEvents) + filteredHeights, err = idx.matchRange(ctx, qr, prefix, filteredHeights, true, matchEvents, heightInfo) if err != nil { return nil, err } @@ -199,7 +194,7 @@ func (idx *BlockerIndexer) Search(ctx context.Context, q *query.Query) ([]int64, break } } else { - filteredHeights, err = idx.matchRange(ctx, qr, prefix, filteredHeights, false, matchEvents) + filteredHeights, err = idx.matchRange(ctx, qr, prefix, filteredHeights, false, matchEvents, heightInfo) if err != nil { return nil, err } @@ -220,7 +215,7 @@ func (idx *BlockerIndexer) Search(ctx context.Context, q *query.Query) ([]int64, } if !heightsInitialized { - filteredHeights, err = idx.match(ctx, c, startKey, filteredHeights, true, matchEvents, height, heightRanges) + filteredHeights, err = idx.match(ctx, c, startKey, filteredHeights, true, matchEvents, heightInfo) if err != nil { return nil, err } @@ -233,7 +228,7 @@ func (idx *BlockerIndexer) Search(ctx context.Context, q *query.Query) ([]int64, break } } else { - filteredHeights, err = idx.match(ctx, c, startKey, filteredHeights, false, matchEvents, height, heightRanges) + filteredHeights, err = idx.match(ctx, c, startKey, filteredHeights, false, matchEvents, heightInfo) if err != nil { return nil, err } @@ -242,6 +237,7 @@ func (idx *BlockerIndexer) Search(ctx context.Context, q *query.Query) ([]int64, // fetch matching heights results = make([]int64, 0, len(filteredHeights)) + resultMap := make(map[int64]struct{}) for _, hBz := range filteredHeights { h := int64FromBytes(hBz) @@ -249,7 +245,9 @@ func (idx *BlockerIndexer) Search(ctx context.Context, q *query.Query) ([]int64, if err != nil { return nil, err } - if ok { + _, okHeight := resultMap[h] + if ok && !okHeight { + resultMap[h] = struct{}{} results = append(results, h) } @@ -279,6 +277,7 @@ func (idx *BlockerIndexer) matchRange( filteredHeights map[string][]byte, firstRun bool, matchEvents bool, + heightInfo HeightInfo, ) (map[string][]byte, error) { // A previous match was attempted but resulted in no matches, so we return @@ -317,6 +316,13 @@ LOOP: if err != nil { continue LOOP } + + if matchEvents && qr.Key != types.BlockHeightKey { + keyHeight, err := parseHeightFromEventKey(it.Key()) + if err != nil || !checkHeightConditions(heightInfo, keyHeight) { + continue LOOP + } + } if checkBounds(qr, v) { idx.setTmpHeights(tmpHeights, it, matchEvents) } @@ -407,8 +413,7 @@ func (idx *BlockerIndexer) match( filteredHeights map[string][]byte, firstRun bool, matchEvents bool, - height int64, - heightRanges indexer.QueryRange, + heightInfo HeightInfo, ) (map[string][]byte, error) { // A previous match was attempted but resulted in no matches, so we return @@ -429,20 +434,11 @@ func (idx *BlockerIndexer) match( for ; it.Valid(); it.Next() { if matchEvents { - - if heightRanges.Key != "" { - eventHeight, err := parseHeightFromEventKey(it.Key()) - if err != nil || !checkBounds(heightRanges, eventHeight) { - continue - } - } else { - if height != 0 { - eventHeight, _ := parseHeightFromEventKey(it.Key()) - if eventHeight != height { - continue - } - } + keyHeight, err := parseHeightFromEventKey(it.Key()) + if err != nil || !checkHeightConditions(heightInfo, keyHeight) { + continue } + } idx.setTmpHeights(tmpHeights, it, matchEvents) @@ -468,6 +464,11 @@ func (idx *BlockerIndexer) match( defer it.Close() for ; it.Valid(); it.Next() { + keyHeight, err := parseHeightFromEventKey(it.Key()) + if err != nil || !checkHeightConditions(heightInfo, keyHeight) { + continue + } + idx.setTmpHeights(tmpHeights, it, matchEvents) select { @@ -501,6 +502,10 @@ func (idx *BlockerIndexer) match( } if strings.Contains(eventValue, c.Operand.(string)) { + keyHeight, err := parseHeightFromEventKey(it.Key()) + if err != nil || !checkHeightConditions(heightInfo, keyHeight) { + continue + } idx.setTmpHeights(tmpHeights, it, matchEvents) } diff --git a/state/indexer/block/kv/kv_test.go b/state/indexer/block/kv/kv_test.go index 4cc1f2ec81..7c10deddae 100644 --- a/state/indexer/block/kv/kv_test.go +++ b/state/indexer/block/kv/kv_test.go @@ -117,10 +117,18 @@ func TestBlockIndexer(t *testing.T) { q: query.MustParse("end_event.foo >= 100"), results: []int64{1}, }, + "end_event.foo > 100": { + q: query.MustParse("end_event.foo > 100"), + results: []int64{}, + }, "block.height > 2 AND end_event.foo <= 8": { q: query.MustParse("block.height > 2 AND end_event.foo <= 8"), results: []int64{4, 6, 8}, }, + "block.height >= 2 AND end_event.foo < 8": { + q: query.MustParse("block.height >= 2 AND end_event.foo < 8"), + results: []int64{2, 4, 6}, + }, "begin_event.proposer CONTAINS 'FFFFFFF'": { q: query.MustParse("begin_event.proposer CONTAINS 'FFFFFFF'"), results: []int64{}, @@ -129,6 +137,10 @@ func TestBlockIndexer(t *testing.T) { q: query.MustParse("begin_event.proposer CONTAINS 'FCAA001'"), results: []int64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}, }, + "end_event.foo CONTAINS '1'": { + q: query.MustParse("end_event.foo CONTAINS '1'"), + results: []int64{1, 10}, + }, } for name, tc := range testCases { @@ -177,7 +189,7 @@ func TestBlockIndexerMulti(t *testing.T) { }, { Key: []byte("bar"), - Value: []byte("400"), + Value: []byte("500"), Index: true, }, }, @@ -235,38 +247,106 @@ func TestBlockIndexerMulti(t *testing.T) { q: query.MustParse("match.events = 1 AND block.height = 1"), results: []int64{1}, }, + "query return all events from a height - exact - no match.events": { + q: query.MustParse("block.height = 1"), + results: []int64{1}, + }, "query return all events from a height - exact (deduplicate height)": { q: query.MustParse("match.events = 1 AND block.height = 1 AND block.height = 2"), results: []int64{1}, }, + "query return all events from a height - exact (deduplicate height) - no match.events": { + q: query.MustParse("block.height = 1 AND block.height = 2"), + results: []int64{1}, + }, "query return all events from a height - range": { q: query.MustParse("match.events = 1 AND block.height < 2 AND block.height > 0 AND block.height > 0"), results: []int64{1}, }, + "query return all events from a height - range - no match.events": { + q: query.MustParse("block.height < 2 AND block.height > 0 AND block.height > 0"), + results: []int64{1}, + }, + "query return all events from a height - range 2": { + q: query.MustParse("match.events = 1 AND block.height < 3 AND block.height < 2 AND block.height > 0 AND block.height > 0"), + results: []int64{1}, + }, + "query return all events from a height - range 3": { + q: query.MustParse("match.events = 1 AND block.height < 1 AND block.height > 1"), + results: []int64{}, + }, "query matches fields from same event": { q: query.MustParse("match.events = 1 AND end_event.bar < 300 AND end_event.foo = 100 AND block.height > 0 AND block.height <= 2"), results: []int64{1, 2}, }, + "query matches fields from same event - no match.events": { + q: query.MustParse("end_event.bar < 300 AND end_event.foo = 100 AND block.height > 0 AND block.height <= 2"), + results: []int64{1, 2}, + }, "query matches fields from multiple events": { q: query.MustParse("match.events = 1 AND end_event.foo = 100 AND end_event.bar = 400 AND block.height = 2"), results: []int64{}, }, + "query matches fields from multiple events 2": { + q: query.MustParse("match.events = 1 AND end_event.foo = 100 AND end_event.bar > 200 AND block.height > 0 AND block.height < 3"), + results: []int64{}, + }, + "query matches fields from multiple events 2 - match.events set to 0": { + q: query.MustParse("match.events = 0 AND end_event.foo = 100 AND end_event.bar > 200 AND block.height > 0 AND block.height < 3"), + results: []int64{1, 2}, + }, "deduplication test - match.events only at beginning": { q: query.MustParse("end_event.foo = 100 AND end_event.bar = 400 AND block.height = 2 AND match.events = 1"), results: []int64{2}, }, + "deduplication test - match.events only at beginning 2": { + q: query.MustParse("end_event.foo = 100 AND match.events = 1 AND end_event.bar = 400 AND block.height = 2"), + results: []int64{2}, + }, "deduplication test - match.events multiple": { q: query.MustParse("match.events = 1 AND end_event.foo = 100 AND end_event.bar = 400 AND block.height = 2 AND match.events = 1"), results: []int64{}, }, + "deduplication test - match.events multiple 2": { + q: query.MustParse("match.events = 1 AND end_event.foo = 100 AND match.events = 1 AND end_event.bar = 400 AND block.height = 2"), + results: []int64{}, + }, "query matches fields from multiple events allowed": { q: query.MustParse("end_event.foo = 100 AND end_event.bar = 400"), + results: []int64{2}, + }, + "query matches all fields from multiple events": { + q: query.MustParse("match.events = 1 AND end_event.bar > 100 AND end_event.bar <= 500"), results: []int64{1, 2}, }, - "query matches fields from all events whose attribute is within range": { - q: query.MustParse("match.events = 1 AND end_event.foo < 300 AND block.height = 2"), + "query matches all fields from multiple events - no match.events": { + q: query.MustParse("end_event.bar > 100 AND end_event.bar <= 500"), results: []int64{1, 2}, }, + "query matches fields from all events whose attribute is within range": { + q: query.MustParse("match.events = 1 AND block.height = 2 AND end_event.foo < 300"), + results: []int64{2}, + }, + "query using CONTAINS matches fields from all events whose attribute is within range": { + q: query.MustParse("match.events = 1 AND block.height = 2 AND end_event.foo CONTAINS '30'"), + results: []int64{2}, + }, + "query with height range and height equality - should ignore equality": { + q: query.MustParse("match.events = 1 AND block.height = 2 AND end_event.foo >= 100 AND block.height < 2"), + results: []int64{1}, + }, + "query with non-existent field": { + q: query.MustParse("match.events = 1 AND end_event.baz = 100"), + results: []int64{}, + }, + "query with non-existent field - no match.events": { + q: query.MustParse("end_event.baz = 100"), + results: []int64{}, + }, + "query with non-existent type": { + q: query.MustParse("match.events = 1 AND end_event_xyz.foo = 100"), + results: []int64{}, + }, } for name, tc := range testCases { diff --git a/state/indexer/block/kv/util.go b/state/indexer/block/kv/util.go index e4a328d33a..6dbf1b181a 100644 --- a/state/indexer/block/kv/util.go +++ b/state/indexer/block/kv/util.go @@ -8,9 +8,18 @@ import ( "github.com/google/orderedcode" "github.com/cometbft/cometbft/libs/pubsub/query" + "github.com/cometbft/cometbft/state/indexer" "github.com/cometbft/cometbft/types" ) +type HeightInfo struct { + heightRange indexer.QueryRange + height int64 + heightEqIdx int + onlyHeightRange bool + onlyHeightEq bool +} + func intInSlice(a int, list []int) bool { for _, b := range list { if b == a { @@ -133,23 +142,52 @@ func lookForHeight(conditions []query.Condition) (int64, bool, int) { return 0, false, -1 } -func dedupHeight(conditions []query.Condition) (dedupConditions []query.Condition, height int64, found bool, idx int) { - idx = -1 - for i, c := range conditions { - if c.CompositeKey == types.BlockHeightKey && c.Op == query.OpEqual { - if found { - continue +// Remove all occurrences of height equality queries except one. While we are traversing the conditions, check whether the only condition in +// addition to match events is the height equality or height range query. At the same time, if we do have a height range condition +// ignore the height equality condition. If a height equality exists, place the condition index in the query and the desired height +// into the heightInfo struct +func dedupHeight(conditions []query.Condition) (dedupConditions []query.Condition, heightInfo HeightInfo, found bool) { + heightInfo.heightEqIdx = -1 + heightRangeExists := false + var heightCondition []query.Condition + heightInfo.onlyHeightEq = true + heightInfo.onlyHeightRange = true + for _, c := range conditions { + if c.CompositeKey == types.BlockHeightKey { + if c.Op == query.OpEqual { + if found || heightRangeExists { + continue + } else { + heightCondition = append(heightCondition, c) + heightInfo.height = c.Operand.(int64) + found = true + } } else { + heightInfo.onlyHeightEq = false + heightRangeExists = true dedupConditions = append(dedupConditions, c) - height = c.Operand.(int64) - found = true - idx = i } } else { + if c.CompositeKey != types.MatchEventKey { + heightInfo.onlyHeightRange = false + heightInfo.onlyHeightEq = false + } dedupConditions = append(dedupConditions, c) } } - return + if !heightRangeExists && len(heightCondition) != 0 { + heightInfo.heightEqIdx = len(dedupConditions) + heightInfo.onlyHeightRange = false + dedupConditions = append(dedupConditions, heightCondition...) + } else { + // If we found a range make sure we set the hegiht idx to -1 as the height equality + // will be removed + heightInfo.heightEqIdx = -1 + heightInfo.height = 0 + found = false + heightInfo.onlyHeightEq = false + } + return dedupConditions, heightInfo, found } func dedupMatchEvents(conditions []query.Condition) ([]query.Condition, bool) { @@ -158,7 +196,7 @@ func dedupMatchEvents(conditions []query.Condition) ([]query.Condition, bool) { for i, c := range conditions { if c.CompositeKey == types.MatchEventKey { // Match events should be added only via RPC as the very first query condition - if i == 0 { + if i == 0 && c.Op == query.OpEqual && c.Operand.(int64) == 1 { dedupConditions = append(dedupConditions, c) matchEvents = true } @@ -169,3 +207,16 @@ func dedupMatchEvents(conditions []query.Condition) ([]query.Condition, bool) { } return dedupConditions, matchEvents } + +func checkHeightConditions(heightInfo HeightInfo, keyHeight int64) bool { + if heightInfo.heightRange.Key != "" { + if !checkBounds(heightInfo.heightRange, keyHeight) { + return false + } + } else { + if heightInfo.height != 0 && keyHeight != heightInfo.height { + return false + } + } + return true +} diff --git a/state/indexer/query_range.go b/state/indexer/query_range.go index 1ca04967b7..3586102d47 100644 --- a/state/indexer/query_range.go +++ b/state/indexer/query_range.go @@ -4,6 +4,7 @@ import ( "time" "github.com/cometbft/cometbft/libs/pubsub/query" + "github.com/cometbft/cometbft/types" ) // QueryRanges defines a mapping between a composite event key and a QueryRange. @@ -75,8 +76,65 @@ func (qr QueryRange) UpperBoundValue() interface{} { } } +// LookForRangesWithHeight returns a mapping of QueryRanges and the matching indexes in +// the provided query conditions. If we are matching attributes within events +// we need to remember the height range from the condition +func LookForRangesWithHeight(conditions []query.Condition) (ranges QueryRanges, indexes []int, heightRange QueryRange) { + ranges = make(QueryRanges) + for i, c := range conditions { + heightKey := false + if IsRangeOperation(c.Op) { + r, ok := ranges[c.CompositeKey] + if !ok { + r = QueryRange{Key: c.CompositeKey} + if c.CompositeKey == types.BlockHeightKey || c.CompositeKey == types.TxHeightKey { + heightRange = QueryRange{Key: c.CompositeKey} + heightKey = true + } + } + + switch c.Op { + case query.OpGreater: + if heightKey { + heightRange.LowerBound = c.Operand + } + r.LowerBound = c.Operand + + case query.OpGreaterEqual: + r.IncludeLowerBound = true + r.LowerBound = c.Operand + if heightKey { + heightRange.IncludeLowerBound = true + heightRange.LowerBound = c.Operand + } + + case query.OpLess: + r.UpperBound = c.Operand + if heightKey { + heightRange.UpperBound = c.Operand + } + + case query.OpLessEqual: + r.IncludeUpperBound = true + r.UpperBound = c.Operand + if heightKey { + heightRange.IncludeUpperBound = true + heightRange.UpperBound = c.Operand + } + } + + ranges[c.CompositeKey] = r + indexes = append(indexes, i) + } + } + + return ranges, indexes, heightRange +} + // LookForRanges returns a mapping of QueryRanges and the matching indexes in // the provided query conditions. +// +// Deprecated: This function will be replaced with LookForRangesWithHeight func LookForRanges(conditions []query.Condition) (ranges QueryRanges, indexes []int) { ranges = make(QueryRanges) for i, c := range conditions { diff --git a/state/txindex/kv/kv.go b/state/txindex/kv/kv.go index c0ff904e0a..f6534abbe1 100644 --- a/state/txindex/kv/kv.go +++ b/state/txindex/kv/kv.go @@ -230,39 +230,54 @@ func (txi *TxIndex) Search(ctx context.Context, q *query.Query) ([]*abci.TxResul // return heights where the conditions are true within the same event // and set the matchEvents to true conditions, matchEvents = dedupMatchEvents(conditions) + // conditions to skip because they're handled before "everything else" + skipIndexes := make([]int, 0) if matchEvents { matchEventIdx = 0 - } else { - matchEventIdx = -1 + skipIndexes = append(skipIndexes, matchEventIdx) } - // conditions to skip because they're handled before "everything else" - skipIndexes := make([]int, 0) - - if matchEventIdx != -1 { - skipIndexes = append(skipIndexes, matchEventIdx) + // if there is a height condition ("tx.height=3"), extract it + // var height int64 + // var heightIdx int + var heightInfo HeightInfo + if matchEvents { + // If we are not matching events and tx.height = 3 occurs more than once, the later value will + // overwrite the first one. For match.events it will create problems. + conditions, heightInfo = dedupHeight(conditions) + } else { + heightInfo.height, heightInfo.heightEqIdx = lookForHeight(conditions) + } + if matchEvents && !heightInfo.onlyHeightEq { + skipIndexes = append(skipIndexes, heightInfo.heightEqIdx) } // 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) - var heightRanges indexer.QueryRange + //If we have a query range over height and want to still look for + // specific event values we do not want to simply return all + // transactios in this height range. We remember the height range info + // and pass it on to match() to take into account when processing events. + ranges, rangeIndexes, heightRange := indexer.LookForRangesWithHeight(conditions) + heightInfo.heightRange = heightRange + if len(ranges) > 0 { skipIndexes = append(skipIndexes, rangeIndexes...) for _, qr := range ranges { - //If we have a query range over height and want to still look for - // specific event values we do not want to simply return all - // transactios in this height range. We remember the height range info - // and pass it on to match() to take into account when processing events. - if qr.Key == types.TxHeightKey && matchEvents { - heightRanges = qr + // If we have additional constraints and want to query per event + // attributes, we cannot simply return all blocks for a height. + // But we remember the height we want to find and forward it to + // match(). If we only have the height constraint and match.events keyword + // in the query (the second part of the ||), we don't need to query + // per event conditions and return all events within the height range. + if qr.Key == types.TxHeightKey && matchEvents && !heightInfo.onlyHeightRange { continue } if !hashesInitialized { - filteredHashes = txi.matchRange(ctx, qr, startKey(qr.Key), filteredHashes, true, matchEvents) + filteredHashes = txi.matchRange(ctx, qr, startKey(qr.Key), filteredHashes, true, matchEvents, heightInfo) hashesInitialized = true // Ignore any remaining conditions if the first condition resulted @@ -271,25 +286,11 @@ func (txi *TxIndex) Search(ctx context.Context, q *query.Query) ([]*abci.TxResul break } } else { - filteredHashes = txi.matchRange(ctx, qr, startKey(qr.Key), filteredHashes, false, matchEvents) + filteredHashes = txi.matchRange(ctx, qr, startKey(qr.Key), filteredHashes, false, matchEvents, heightInfo) } } } - // if there is a height condition ("tx.height=3"), extract it - var height int64 - var heightIdx int - if matchEvents { - // If we are not matching events and tx.height = 3 occurs more than once, the later value will - // overwrite the first one. For match.events it will create problems. - conditions, height, heightIdx = dedupHeight(conditions) - } else { - height, heightIdx = lookForHeight(conditions) - } - if matchEvents && (len(conditions) != 2) { - skipIndexes = append(skipIndexes, heightIdx) - } - // for all other conditions for i, c := range conditions { if intInSlice(i, skipIndexes) { @@ -297,7 +298,7 @@ func (txi *TxIndex) Search(ctx context.Context, q *query.Query) ([]*abci.TxResul } if !hashesInitialized { - filteredHashes = txi.match(ctx, c, startKeyForCondition(c, height), filteredHashes, true, matchEvents, height, heightRanges) + filteredHashes = txi.match(ctx, c, startKeyForCondition(c, heightInfo.height), filteredHashes, true, matchEvents, heightInfo) hashesInitialized = true // Ignore any remaining conditions if the first condition resulted @@ -306,18 +307,22 @@ func (txi *TxIndex) Search(ctx context.Context, q *query.Query) ([]*abci.TxResul break } } else { - filteredHashes = txi.match(ctx, c, startKeyForCondition(c, height), filteredHashes, false, matchEvents, height, heightRanges) + filteredHashes = txi.match(ctx, c, startKeyForCondition(c, heightInfo.height), filteredHashes, false, matchEvents, heightInfo) } } results := make([]*abci.TxResult, 0, len(filteredHashes)) + resultMap := make(map[string]struct{}) for _, h := range filteredHashes { res, err := txi.Get(h) if err != nil { return nil, fmt.Errorf("failed to get Tx{%X}: %w", h, err) } - results = append(results, res) - + hashString := string(h) + if _, ok := resultMap[hashString]; !ok { + resultMap[hashString] = struct{}{} + results = append(results, res) + } // Potentially exit early. select { case <-ctx.Done(): @@ -369,8 +374,7 @@ func (txi *TxIndex) match( filteredHashes map[string][]byte, firstRun bool, matchEvents bool, - height int64, - heightRanges indexer.QueryRange, + heightInfo HeightInfo, ) map[string][]byte { // A previous match was attempted but resulted in no matches, so we return // no matches (assuming AND operand). @@ -392,18 +396,13 @@ func (txi *TxIndex) match( // If we have a height range in a query, we need only transactions // for this height - if heightRanges.Key != "" { - eventHeight, err := extractHeightFromKey(it.Key()) - if err != nil || !checkBounds(heightRanges, eventHeight) { - continue - } - } else if height != 0 { - // If we have a particular height in the query, return only transactions - // matching this height. - eventHeight, err := extractHeightFromKey(it.Key()) - if eventHeight != height || err != nil { + + if matchEvents { + keyHeight, err := extractHeightFromKey(it.Key()) + if err != nil || !checkHeightConditions(heightInfo, keyHeight) { continue } + } txi.setTmpHashes(tmpHashes, it, matchEvents) @@ -428,6 +427,13 @@ func (txi *TxIndex) match( defer it.Close() for ; it.Valid(); it.Next() { + if matchEvents { + keyHeight, err := extractHeightFromKey(it.Key()) + if err != nil || !checkHeightConditions(heightInfo, keyHeight) { + continue + } + + } txi.setTmpHashes(tmpHashes, it, matchEvents) // Potentially exit early. @@ -457,6 +463,12 @@ func (txi *TxIndex) match( } if strings.Contains(extractValueFromKey(it.Key()), c.Operand.(string)) { + if matchEvents { + keyHeight, err := extractHeightFromKey(it.Key()) + if err != nil || !checkHeightConditions(heightInfo, keyHeight) { + continue + } + } txi.setTmpHashes(tmpHashes, it, matchEvents) } @@ -516,6 +528,7 @@ func (txi *TxIndex) matchRange( filteredHashes map[string][]byte, firstRun bool, matchEvents bool, + heightInfo HeightInfo, ) map[string][]byte { // A previous match was attempted but resulted in no matches, so we return // no matches (assuming AND operand). @@ -543,6 +556,12 @@ LOOP: continue LOOP } + if matchEvents && qr.Key != types.TxHeightKey { + keyHeight, err := extractHeightFromKey(it.Key()) + if err != nil || !checkHeightConditions(heightInfo, keyHeight) { + continue LOOP + } + } if checkBounds(qr, v) { txi.setTmpHashes(tmpHashes, it, matchEvents) } diff --git a/state/txindex/kv/kv_test.go b/state/txindex/kv/kv_test.go index 98681a4328..90bc350a8a 100644 --- a/state/txindex/kv/kv_test.go +++ b/state/txindex/kv/kv_test.go @@ -84,10 +84,13 @@ func TestTxSearch(t *testing.T) { }{ // search by hash {fmt.Sprintf("tx.hash = '%X'", hash), 1}, + // search by hash (lower) + {fmt.Sprintf("tx.hash = '%x'", hash), 1}, // search by exact match (one key) {"account.number = 1", 1}, // search by exact match (two keys) {"account.number = 1 AND account.owner = 'Ivan'", 1}, + {"account.owner = 'Ivan' AND account.number = 1", 1}, // search by exact match (two keys) {"account.number = 1 AND account.owner = 'Vlad'", 0}, {"account.owner = 'Vlad' AND account.number = 1", 0}, @@ -95,16 +98,21 @@ func TestTxSearch(t *testing.T) { {"account.owner = 'Vlad' AND account.number >= 1", 0}, {"account.number <= 0", 0}, {"account.number <= 0 AND account.owner = 'Ivan'", 0}, + {"account.number < 10000 AND account.owner = 'Ivan'", 1}, // search using a prefix of the stored value {"account.owner = 'Iv'", 0}, // search by range {"account.number >= 1 AND account.number <= 5", 1}, + // search by range and another key + {"account.number >= 1 AND account.owner = 'Ivan' AND account.number <= 5", 1}, // search by range (lower bound) {"account.number >= 1", 1}, // search by range (upper bound) {"account.number <= 5", 1}, + {"account.number <= 1", 1}, // search using not allowed key {"not_allowed = 'boom'", 0}, + {"not_allowed = 'Vlad'", 0}, // search for not existing tx result {"account.number >= 2 AND account.number <= 5", 0}, // search using not existing key @@ -113,12 +121,18 @@ func TestTxSearch(t *testing.T) { {"account.owner CONTAINS 'an'", 1}, // search for non existing value using CONTAINS {"account.owner CONTAINS 'Vlad'", 0}, + {"account.owner CONTAINS 'Ivann'", 0}, + {"account.owner CONTAINS 'IIvan'", 0}, + {"account.owner CONTAINS 'Iva n'", 0}, + {"account.owner CONTAINS ' Ivan'", 0}, + {"account.owner CONTAINS 'Ivan '", 0}, // search using the wrong key (of numeric type) using CONTAINS {"account.number CONTAINS 'Iv'", 0}, // search using EXISTS {"account.number EXISTS", 1}, // search using EXISTS for non existing key {"account.date EXISTS", 0}, + {"not_allowed EXISTS", 0}, } ctx := context.Background() @@ -146,6 +160,7 @@ func TestTxSearchEventMatch(t *testing.T) { txResult := txResultWithEvents([]abci.Event{ {Type: "account", Attributes: []abci.EventAttribute{{Key: []byte("number"), Value: []byte("1"), Index: true}, {Key: []byte("owner"), Value: []byte("Ana"), Index: true}}}, {Type: "account", Attributes: []abci.EventAttribute{{Key: []byte("number"), Value: []byte("2"), Index: true}, {Key: []byte("owner"), Value: []byte("Ivan"), Index: true}}}, + {Type: "account", Attributes: []abci.EventAttribute{{Key: []byte("number"), Value: []byte("3"), Index: false}, {Key: []byte("owner"), Value: []byte("Mickey"), Index: false}}}, {Type: "", Attributes: []abci.EventAttribute{{Key: []byte("not_allowed"), Value: []byte("Vlad"), Index: true}}}, }) @@ -156,6 +171,18 @@ func TestTxSearchEventMatch(t *testing.T) { q string resultsLength int }{ + "Don't match non-indexed events": { + q: "match.events = 1 AND account.number = 3 AND account.owner = 'Mickey'", + resultsLength: 0, + }, + "Return all events from a height with range": { + q: "match.events = 1 AND tx.height > 0", + resultsLength: 1, + }, + "Return all events from a height with range 2": { + q: "match.events = 1 AND tx.height <= 1", + resultsLength: 1, + }, "Return all events from a height": { q: "match.events = 1 AND tx.height = 1", resultsLength: 1, @@ -165,7 +192,7 @@ func TestTxSearchEventMatch(t *testing.T) { resultsLength: 1, }, "Match attributes with height range and event": { - q: "match.events = 1 AND tx.height < 2 AND tx.height > 0 AND account.number = 1 AND account.owner CONTAINS 'Ana'", + q: "match.events = 1 AND tx.height < 2 AND tx.height > 0 AND account.number = 1 AND account.owner CONTAINS 'Ana' AND account.owner CONTAINS 'An'", resultsLength: 1, }, "Match attributes with height range and event - no match": { @@ -176,6 +203,14 @@ func TestTxSearchEventMatch(t *testing.T) { q: "tx.height < 2 AND tx.height > 0 AND account.number = 2 AND account.owner = 'Ana' AND match.events = 1", resultsLength: 1, }, + "Deduplucation test - should return nothing if attribute repeats multiple times": { + q: "match.events = 0 AND tx.height < 2 AND account.number = 3 AND account.number = 2 AND account.number = 5", + resultsLength: 0, + }, + "Deduplucation test - should return nothing if attribute repeats multiple times with match events": { + q: "match.events = 1 AND tx.height < 2 AND account.number = 3 AND account.number = 2 AND account.number = 5", + resultsLength: 0, + }, "Deduplucation test - match events multiple": { q: "match.events = 1 AND tx.height < 2 AND tx.height > 0 AND account.number = 2 AND account.owner = 'Ana' AND match.events = 1", resultsLength: 0, @@ -188,10 +223,18 @@ func TestTxSearchEventMatch(t *testing.T) { q: "account.number < 2 AND account.owner = 'Ivan'", resultsLength: 1, }, + " Match range with match events set to 0": { + q: "match.events = 0 AND account.number < 2 AND account.owner = 'Ivan' AND tx.height > 0", + resultsLength: 1, + }, " Match range with match events": { - q: "match.events = 1 AND account.number < 2 AND account.owner = 'Ivan'", + q: "match.events = 1 AND account.number < 2 AND account.owner = 'Ivan' AND tx.height > 0", resultsLength: 0, }, + " Match range with match events 2": { + q: "match.events = 1 AND account.number <= 2 AND account.owner = 'Ivan' AND tx.height > 0", + resultsLength: 1, + }, } ctx := context.Background() @@ -314,19 +357,80 @@ func TestTxSearchOneTxWithMultipleSameTagsButDifferentValues(t *testing.T) { txResult := txResultWithEvents([]abci.Event{ {Type: "account", Attributes: []abci.EventAttribute{{Key: []byte("number"), Value: []byte("1"), Index: true}}}, {Type: "account", Attributes: []abci.EventAttribute{{Key: []byte("number"), Value: []byte("2"), Index: true}}}, + {Type: "account", Attributes: []abci.EventAttribute{{Key: []byte("number"), Value: []byte("3"), Index: false}}}, }) err := indexer.Index(txResult) require.NoError(t, err) + testCases := []struct { + q string + found bool + }{ + { + q: "match.events = 1 AND account.number >= 1", + found: true, + }, + { + q: "match.events = 1 AND account.number > 2", + found: false, + }, + { + q: "match.events = 1 AND account.number >= 1 AND tx.height = 3 AND tx.height > 0", + found: true, + }, + { + q: "match.events = 1 AND account.number >= 1 AND tx.height > 0 AND tx.height = 3", + found: true, + }, + + // { + // q: "match.events = 1 AND account.number >= 1 AND tx.height = 3 AND tx.height = 2 AND tx.height = 1", + // found: true, + // }, + + { + q: "match.events = 1 AND account.number >= 1 AND tx.height = 1 AND tx.height = 2 AND tx.height = 1", + found: true, + }, + { + q: "match.events = 1 AND account.number >= 1 AND tx.height = 3", + found: false, + }, + { + q: "match.events = 1 AND account.number > 1 AND tx.height < 2", + found: true, + }, + { + q: "match.events = 1 AND account.number >= 2", + found: true, + }, + { + q: "match.events = 1 AND account.number <= 1", + found: true, + }, + { + q: "match.events = 1 AND account.number = 'something'", + found: false, + }, + { + q: "match.events = 1 AND account.number CONTAINS 'bla'", + found: false, + }, + } + ctx := context.Background() - results, err := indexer.Search(ctx, query.MustParse("account.number >= 1")) - assert.NoError(t, err) + for _, tc := range testCases { + results, err := indexer.Search(ctx, query.MustParse(tc.q)) + assert.NoError(t, err) - assert.Len(t, results, 1) - for _, txr := range results { - assert.True(t, proto.Equal(txResult, txr)) + len := 0 + if tc.found { + len = 1 + } + assert.Len(t, results, len) + assert.True(t, !tc.found || proto.Equal(txResult, results[0])) } } diff --git a/state/txindex/kv/utils.go b/state/txindex/kv/utils.go index ba20a22ebb..7a2c26d66a 100644 --- a/state/txindex/kv/utils.go +++ b/state/txindex/kv/utils.go @@ -4,10 +4,19 @@ import ( "fmt" "github.com/cometbft/cometbft/libs/pubsub/query" + "github.com/cometbft/cometbft/state/indexer" "github.com/cometbft/cometbft/types" "github.com/google/orderedcode" ) +type HeightInfo struct { + heightRange indexer.QueryRange + height int64 + heightEqIdx int + onlyHeightRange bool + onlyHeightEq bool +} + // IntInSlice returns true if a is found in the list. func intInSlice(a int, list []int) bool { for _, b := range list { @@ -24,7 +33,7 @@ func dedupMatchEvents(conditions []query.Condition) ([]query.Condition, bool) { for i, c := range conditions { if c.CompositeKey == types.MatchEventKey { // Match events should be added only via RPC as the very first query condition - if i == 0 { + if i == 0 && c.Op == query.OpEqual && c.Operand.(int64) == 1 { dedupConditions = append(dedupConditions, c) matchEvents = true } @@ -54,23 +63,60 @@ func ParseEventSeqFromEventKey(key []byte) (int64, error) { return eventSeq, nil } -func dedupHeight(conditions []query.Condition) (dedupConditions []query.Condition, height int64, idx int) { + +func dedupHeight(conditions []query.Condition) (dedupConditions []query.Condition, heightInfo HeightInfo) { + heightInfo.heightEqIdx = -1 found := false - idx = -1 - height = 0 - for i, c := range conditions { - if c.CompositeKey == types.TxHeightKey && c.Op == query.OpEqual { - if found { - continue + heightRangeExists := false + var heightCondition []query.Condition + heightInfo.onlyHeightEq = true + heightInfo.onlyHeightRange = true + for _, c := range conditions { + if c.CompositeKey == types.TxHeightKey { + if c.Op == query.OpEqual { + if heightRangeExists || found { + continue + } else { + found = true + heightCondition = append(heightCondition, c) + heightInfo.height = c.Operand.(int64) + } } else { + heightInfo.onlyHeightEq = false + heightRangeExists = true dedupConditions = append(dedupConditions, c) - height = c.Operand.(int64) - found = true - idx = i } } else { + if c.CompositeKey != types.MatchEventKey { + heightInfo.onlyHeightRange = false + heightInfo.onlyHeightEq = false + } dedupConditions = append(dedupConditions, c) } } - return + if !heightRangeExists && len(heightCondition) != 0 { + heightInfo.heightEqIdx = len(dedupConditions) + heightInfo.onlyHeightRange = false + dedupConditions = append(dedupConditions, heightCondition...) + } else { + // If we found a range make sure we set the height idx to -1 as the height equality + // will be removed + heightInfo.heightEqIdx = -1 + heightInfo.height = 0 + heightInfo.onlyHeightEq = false + } + return dedupConditions, heightInfo +} + +func checkHeightConditions(heightInfo HeightInfo, keyHeight int64) bool { + if heightInfo.heightRange.Key != "" { + if !checkBounds(heightInfo.heightRange, keyHeight) { + return false + } + } else { + if heightInfo.height != 0 && keyHeight != heightInfo.height { + return false + } + } + return true }