From b9a2f557fe0efdc6bb04a3c00e82a7fe04e04e7b Mon Sep 17 00:00:00 2001 From: tony Date: Mon, 26 Oct 2026 16:40:05 +0800 Subject: [PATCH 1/2] logdb: evaluate narrow topic0-only event filters by seq range scan Claude-Session: https://claude.ai/code/session_01EQzJUxb99n2LVoqvi7ioAx --- logdb/logdb.go | 65 +++++++++------ logdb/plan_test.go | 201 +++++++++++++++++++++++++++++++++++++++++++++ logdb/types.go | 27 +++++- 3 files changed, 265 insertions(+), 28 deletions(-) create mode 100644 logdb/plan_test.go diff --git a/logdb/logdb.go b/logdb/logdb.go index 3bb9e3f016..940219b3d4 100644 --- a/logdb/logdb.go +++ b/logdb/logdb.go @@ -12,6 +12,7 @@ import ( "encoding/hex" "fmt" "math/big" + "strings" "time" "github.com/vechain/thor/v2/block" @@ -229,25 +230,8 @@ func (db *LogDB) Path() string { return db.path } -func (db *LogDB) FilterEvents(ctx context.Context, filter *EventFilter) ([]*Event, error) { - const query = `SELECT e.seq, r0.data, e.blockTime, r1.data, r2.data, e.clauseIndex, r3.data, r4.data, r5.data, r6.data, r7.data, r8.data, e.data -FROM (%v) e - LEFT JOIN ref r0 ON e.blockID = r0.id - LEFT JOIN ref r1 ON e.txID = r1.id - LEFT JOIN ref r2 ON e.txOrigin = r2.id - LEFT JOIN ref r3 ON e.address = r3.id - LEFT JOIN ref r4 ON e.topic0 = r4.id - LEFT JOIN ref r5 ON e.topic1 = r5.id - LEFT JOIN ref r6 ON e.topic2 = r6.id - LEFT JOIN ref r7 ON e.topic3 = r7.id - LEFT JOIN ref r8 ON e.topic4 = r8.id` - - if filter == nil { - return db.queryEvents(ctx, fmt.Sprintf(query, "event")) - } - - metricsHandleEventsFilter(filter) - +// buildEventFilterSubQuery builds the seq-selecting subquery for a non-nil filter. +func buildEventFilterSubQuery(filter *EventFilter) (string, []any, error) { var ( subQuery = "SELECT seq FROM event WHERE 1" args []any @@ -256,11 +240,11 @@ FROM (%v) e if filter.Range != nil { from, err := newSequence(filter.Range.From, 0, 0) if err != nil { - return nil, err + return "", nil, err } to, err := newSequence(filter.Range.To, txIndexMask, logIndexMask) if err != nil { - return nil, err + return "", nil, err } // Always bind both bounds. For an inverted range (To < From) this yields // seq >= from AND seq <= to with to < from — an empty result — instead of @@ -270,17 +254,20 @@ FROM (%v) e } if len(filter.CriteriaSet) > 0 { - subQuery += " AND (" + var sb strings.Builder + sb.WriteString(" AND (") + narrow := filter.Range != nil && filter.Range.To-filter.Range.From < maxScanBlocks for i, c := range filter.CriteriaSet { - cond, cargs := c.toWhereCondition() + cond, cargs := c.toWhereCondition(narrow && c.topic0Only()) if i > 0 { - subQuery += " OR" + sb.WriteString(" OR") } - subQuery += " (" + cond + ")" + sb.WriteString(" (" + cond + ")") args = append(args, cargs...) } - subQuery += ")" + sb.WriteString(")") + subQuery += sb.String() } // Options set: order and LIMIT are applied together in the subquery, so LIMIT takes the ordered window. @@ -296,6 +283,32 @@ FROM (%v) e subQuery += " LIMIT ?, ?" args = append(args, filter.Options.Offset, filter.Options.Limit) } + return subQuery, args, nil +} + +func (db *LogDB) FilterEvents(ctx context.Context, filter *EventFilter) ([]*Event, error) { + const query = `SELECT e.seq, r0.data, e.blockTime, r1.data, r2.data, e.clauseIndex, r3.data, r4.data, r5.data, r6.data, r7.data, r8.data, e.data +FROM (%v) e + LEFT JOIN ref r0 ON e.blockID = r0.id + LEFT JOIN ref r1 ON e.txID = r1.id + LEFT JOIN ref r2 ON e.txOrigin = r2.id + LEFT JOIN ref r3 ON e.address = r3.id + LEFT JOIN ref r4 ON e.topic0 = r4.id + LEFT JOIN ref r5 ON e.topic1 = r5.id + LEFT JOIN ref r6 ON e.topic2 = r6.id + LEFT JOIN ref r7 ON e.topic3 = r7.id + LEFT JOIN ref r8 ON e.topic4 = r8.id` + + if filter == nil { + return db.queryEvents(ctx, fmt.Sprintf(query, "event")) + } + + metricsHandleEventsFilter(filter) + + subQuery, args, err := buildEventFilterSubQuery(filter) + if err != nil { + return nil, err + } subQuery = "SELECT e.* FROM (" + subQuery + ") s LEFT JOIN event e ON s.seq = e.seq" diff --git a/logdb/plan_test.go b/logdb/plan_test.go new file mode 100644 index 0000000000..3b5f3da2e4 --- /dev/null +++ b/logdb/plan_test.go @@ -0,0 +1,201 @@ +// Copyright (c) 2018 The VeChainThor developers + +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package logdb + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/vechain/thor/v2/block" + "github.com/vechain/thor/v2/thor" + "github.com/vechain/thor/v2/tx" +) + +const scanPlan = "USING INTEGER PRIMARY KEY (rowid>? AND rowid= ? AND seq <= ? AND (") + for i, c := range f.CriteriaSet { + cond, cargs := c.toWhereCondition(false) + if i > 0 { + sb.WriteString(" OR") + } + sb.WriteString(" (" + cond + ")") + args = append(args, cargs...) + } + sb.WriteString(")") + sub += sb.String() //nolint:gosec // test query from fixed fragments + if f.Order == DESC { + sub += " ORDER BY seq DESC LIMIT ?, ?" + } else { + sub += " ORDER BY seq ASC LIMIT ?, ?" + } + args = append(args, f.Options.Offset, f.Options.Limit) + rows, err := db.readDB.Query(sub, args...) + require.NoError(t, err) + defer rows.Close() + var out []key + for rows.Next() { + var s sequence + require.NoError(t, rows.Scan(&s)) + out = append(out, key{s.BlockNumber(), s.TxIndex(), s.LogIndex()}) + } + require.NoError(t, rows.Err()) + return out + } + + for _, set := range [][]*EventCriteria{{crit(0)}, {crit(1), crit(2)}, {crit(0), crit(1), crit(2)}} { + for _, order := range []Order{ASC, DESC} { + for _, rng := range []Range{{5, 5}, {3, 12}, {0, 100}} { + for _, opt := range []Options{{0, 1000}, {2, 5}, {1000, 5}} { + f := &EventFilter{CriteriaSet: set, Range: &rng, Options: &opt, Order: order} + name := fmt.Sprintf("%d/%v/%v/%v", len(set), order, rng, opt) + t.Run(name, func(t *testing.T) { + sub, _, err := buildEventFilterSubQuery(f) + require.NoError(t, err) + require.Contains(t, sub, "+topic0") + + evs, err := db.FilterEvents(context.Background(), f) + require.NoError(t, err) + var got []key + for _, e := range evs { + got = append(got, key{e.BlockNumber, e.TxIndex, e.LogIndex}) + } + want := indexPath(f) + assert.Equal(t, want, got) + if rng.To-rng.From == 0 && opt.Offset == 0 && opt.Limit == 1000 { + assert.NotEmpty(t, got) + } + }) + } + } + } + } +} diff --git a/logdb/types.go b/logdb/types.go index 627a0674da..69cda87540 100644 --- a/logdb/types.go +++ b/logdb/types.go @@ -64,7 +64,26 @@ type EventCriteria struct { Topics [5]*thor.Bytes32 } -func (c *EventCriteria) toWhereCondition() (cond string, args []any) { +// maxScanBlocks bounds the block span over which topic0-only criteria are evaluated by a seq +// range scan rather than the topic0 index; past this width the scan stops paying off for sparse topics. +const maxScanBlocks = 100_000 + +// topic0Only reports whether the criterion constrains nothing but topic0. +func (c *EventCriteria) topic0Only() bool { + if c.Address != nil || c.Topics[0] == nil { + return false + } + for _, t := range c.Topics[1:] { + if t != nil { + return false + } + } + return true +} + +// toWhereCondition renders the criterion. With noTopic0Index set, topic0 is written as +topic0 so +// SQLite cannot use it for an index lookup and falls back to the seq range. +func (c *EventCriteria) toWhereCondition(noTopic0Index bool) (cond string, args []any) { cond = "1" if c.Address != nil { cond += " AND address = " + refIDQuery @@ -72,7 +91,11 @@ func (c *EventCriteria) toWhereCondition() (cond string, args []any) { } for i, topic := range c.Topics { if topic != nil { - cond += fmt.Sprintf(" AND topic%v = ", i) + refIDQuery + if i == 0 && noTopic0Index { + cond += " AND +topic0 = " + refIDQuery + } else { + cond += fmt.Sprintf(" AND topic%v = ", i) + refIDQuery + } args = append(args, removeLeadingZeros(topic.Bytes())) } } From 18814164e4570476fbaf8bd0b0492c9e6d3e5025 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Wed, 28 Oct 2026 10:37:15 +0000 Subject: [PATCH 2/2] logdb: range-scan every address-less criterion and cap open-ended spans (#1694) Co-authored-by: tony --- api/events_types.go | 17 ++++---- api/events_types_test.go | 29 ++++++++++--- logdb/logdb.go | 16 ++++--- logdb/plan_test.go | 92 ++++++++++++++++++++++------------------ logdb/types.go | 34 ++++++--------- 5 files changed, 104 insertions(+), 84 deletions(-) diff --git a/api/events_types.go b/api/events_types.go index 18fcd972ea..278ad094cb 100644 --- a/api/events_types.go +++ b/api/events_types.go @@ -233,18 +233,17 @@ func ConvertRange(chain *chain.Chain, r *Range) (*logdb.Range, error) { return &emptyRange, nil } + // open-ended and oversized ranges end a few blocks above the head + to := block.Number(chain.HeadID()) + 6 + if r.To != nil && *r.To < uint64(to) { + to = uint32(*r.To) + } from := uint32(0) if r.From != nil { from = uint32(*r.From) } - - to := uint32(logdb.MaxBlockNumber) - if r.To != nil && *r.To < logdb.MaxBlockNumber { - to = uint32(*r.To) + if from > to { + return &emptyRange, nil } - - return &logdb.Range{ - From: from, - To: to, - }, nil + return &logdb.Range{From: from, To: to}, nil } diff --git a/api/events_types_test.go b/api/events_types_test.go index d25a722cdd..ccedf3b81c 100644 --- a/api/events_types_test.go +++ b/api/events_types_test.go @@ -105,8 +105,7 @@ func testConvertRangeWithBlockRangeTypeWithSwitchedFromAndTo(t *testing.T, chain convertedRng, err := ConvertRange(chain.Repo().NewBestChain(), rng) assert.NoError(t, err) - assert.Equal(t, emptyRange.From, convertedRng.From) - assert.Equal(t, uint32(*rng.To), convertedRng.To) + assert.Equal(t, &emptyRange, convertedRng) } func testConvertRangeWithTimeRangeTypeLessThenGenesis(t *testing.T, chain *testchain.Chain) { @@ -169,6 +168,7 @@ func TestConvertRange_Matrix(t *testing.T) { bestChain := chain.Repo().NewBestChain() genesis := chain.GenesisBlock().Header() bestBlock := chain.Repo().BestBlockSummary().Header + head := bestBlock.Number() // Cases here focus on what TestEventsTypes (which builds ranges via newRange // with non-nil fields) does not cover: the JSON path, omitted-field defaulting, @@ -186,7 +186,7 @@ func TestConvertRange_Matrix(t *testing.T) { { name: "empty object returns full block range", json: `{}`, - expected: &logdb.Range{From: 0, To: uint32(logdb.MaxBlockNumber)}, + expected: &logdb.Range{From: 0, To: head + 6}, }, // Block range - omitted-field defaulting and boundary capping. @@ -196,19 +196,34 @@ func TestConvertRange_Matrix(t *testing.T) { expected: &logdb.Range{From: 0, To: 10}, }, { - name: "block range with omitted to defaults to MaxBlockNumber", + name: "block range with omitted to defaults to head+6", json: `{"unit": "block", "from": 5}`, - expected: &logdb.Range{From: 5, To: uint32(logdb.MaxBlockNumber)}, + expected: &logdb.Range{From: 5, To: head + 6}, }, { name: "block range at MaxBlockNumber", json: `{"unit": "block", "from": 268435455, "to": 268435455}`, - expected: &logdb.Range{From: uint32(logdb.MaxBlockNumber), To: uint32(logdb.MaxBlockNumber)}, + expected: &emptyRange, }, { name: "block range to exceeds MaxBlockNumber is capped", json: `{"unit": "block", "from": 5, "to": 999999999999}`, - expected: &logdb.Range{From: 5, To: uint32(logdb.MaxBlockNumber)}, + expected: &logdb.Range{From: 5, To: head + 6}, + }, + { + name: "block range to above head+6 is capped", + json: fmt.Sprintf(`{"unit": "block", "from": 0, "to": %d}`, head+7), + expected: &logdb.Range{From: 0, To: head + 6}, + }, + { + name: "block range from above head+6 is empty", + json: fmt.Sprintf(`{"unit": "block", "from": %d}`, head+7), + expected: &emptyRange, + }, + { + name: "block range to below head is kept", + json: `{"unit": "block", "from": 0, "to": 0}`, + expected: &logdb.Range{From: 0, To: 0}, }, // Time range - omitted-field defaulting. diff --git a/logdb/logdb.go b/logdb/logdb.go index 940219b3d4..725f38e803 100644 --- a/logdb/logdb.go +++ b/logdb/logdb.go @@ -230,8 +230,14 @@ func (db *LogDB) Path() string { return db.path } -// buildEventFilterSubQuery builds the seq-selecting subquery for a non-nil filter. -func buildEventFilterSubQuery(filter *EventFilter) (string, []any, error) { +// narrowRange reports whether r spans fewer than maxScanBlocks blocks. +func narrowRange(r *Range) bool { + return r != nil && r.To >= r.From && r.To-r.From < maxScanBlocks +} + +// buildEventFilterSubQuery builds the seq-selecting subquery for a non-nil filter. With narrow set, +// criteria whose index lookup the seq range cannot bound are evaluated by a seq range scan instead. +func buildEventFilterSubQuery(filter *EventFilter, narrow bool) (string, []any, error) { var ( subQuery = "SELECT seq FROM event WHERE 1" args []any @@ -256,10 +262,8 @@ func buildEventFilterSubQuery(filter *EventFilter) (string, []any, error) { if len(filter.CriteriaSet) > 0 { var sb strings.Builder sb.WriteString(" AND (") - - narrow := filter.Range != nil && filter.Range.To-filter.Range.From < maxScanBlocks for i, c := range filter.CriteriaSet { - cond, cargs := c.toWhereCondition(narrow && c.topic0Only()) + cond, cargs := c.toWhereCondition(narrow && !c.seqBounded()) if i > 0 { sb.WriteString(" OR") } @@ -305,7 +309,7 @@ FROM (%v) e metricsHandleEventsFilter(filter) - subQuery, args, err := buildEventFilterSubQuery(filter) + subQuery, args, err := buildEventFilterSubQuery(filter, narrowRange(filter.Range)) if err != nil { return nil, err } diff --git a/logdb/plan_test.go b/logdb/plan_test.go index 3b5f3da2e4..0510a5a57f 100644 --- a/logdb/plan_test.go +++ b/logdb/plan_test.go @@ -27,7 +27,7 @@ func TestEventQueryPlanPerCriteriaShape(t *testing.T) { defer db.Close() addr := randAddress() - t0, t1, t2, t3 := randBytes32(), randBytes32(), randBytes32(), randBytes32() + t0, t1, t2, t3, t4 := randBytes32(), randBytes32(), randBytes32(), randBytes32(), randBytes32() crit := func(a *thor.Address, topics ...*thor.Bytes32) *EventCriteria { c := &EventCriteria{Address: a} copy(c.Topics[:], topics) @@ -52,15 +52,17 @@ func TestEventQueryPlanPerCriteriaShape(t *testing.T) { {"six topic0", six, expect{scanPlan, "MULTI-INDEX OR", "MULTI-INDEX OR"}}, {"address", []*EventCriteria{crit(&addr)}, expect{"INDEX event_i0 ", "INDEX event_i0 ", "INDEX event_i0 "}}, {"address+topic0", []*EventCriteria{crit(&addr, &t0)}, expect{"INDEX event_i1 ", "INDEX event_i1 ", "INDEX event_i1 "}}, - {"topic0+topic1", []*EventCriteria{crit(nil, &t0, &t1)}, expect{"INDEX event_i2 ", "INDEX event_i2 ", "INDEX event_i2 "}}, - {"topic0+topic2", []*EventCriteria{crit(nil, &t0, nil, &t2)}, expect{"INDEX event_i3 ", "INDEX event_i3 ", "INDEX event_i3 "}}, - {"topic0+topic3", []*EventCriteria{crit(nil, &t0, nil, nil, &t3)}, expect{"INDEX event_i4 ", "INDEX event_i4 ", "INDEX event_i4 "}}, + {"topic1", []*EventCriteria{crit(nil, nil, &t1)}, expect{scanPlan, "INDEX event_i2 ", "INDEX event_i2 "}}, + {"topic4", []*EventCriteria{crit(nil, nil, nil, nil, nil, &t4)}, expect{scanPlan, "INDEX event_i5 ", "INDEX event_i5 "}}, + {"topic0+topic1", []*EventCriteria{crit(nil, &t0, &t1)}, expect{scanPlan, "INDEX event_i2 ", "INDEX event_i2 "}}, + {"topic0+topic2", []*EventCriteria{crit(nil, &t0, nil, &t2)}, expect{scanPlan, "INDEX event_i3 ", "INDEX event_i3 "}}, + {"topic0+topic3", []*EventCriteria{crit(nil, &t0, nil, nil, &t3)}, expect{scanPlan, "INDEX event_i4 ", "INDEX event_i4 "}}, {"address+topic0+topic1", []*EventCriteria{crit(&addr, &t0, &t1)}, expect{"INDEX event_i2 ", "INDEX event_i2 ", "INDEX event_i2 "}}, {"mixed", []*EventCriteria{crit(nil, &t0), crit(&addr, &t0)}, expect{scanPlan, "MULTI-INDEX OR", "MULTI-INDEX OR"}}, } plan := func(t *testing.T, set []*EventCriteria, r *Range) string { - sub, args, err := buildEventFilterSubQuery(&EventFilter{CriteriaSet: set, Range: r}) + sub, args, err := buildEventFilterSubQuery(&EventFilter{CriteriaSet: set, Range: r}, narrowRange(r)) require.NoError(t, err) rows, err := db.readDB.Query("EXPLAIN QUERY PLAN "+sub, args...) require.NoError(t, err) @@ -102,12 +104,29 @@ func TestEventQueryPlanPerCriteriaShape(t *testing.T) { }) } +func TestNarrowRange(t *testing.T) { + for _, c := range []struct { + name string + rng *Range + want bool + }{ + {"nil", nil, false}, + {"inverted", &Range{From: 10, To: 5}, false}, + {"narrow", &Range{From: 0, To: maxScanBlocks - 1}, true}, + {"wide", &Range{From: 0, To: maxScanBlocks}, false}, + {"single block", &Range{From: 5, To: 5}, true}, + } { + t.Run(c.name, func(t *testing.T) { assert.Equal(t, c.want, narrowRange(c.rng)) }) + } +} + func TestEventFilterScanEquivalence(t *testing.T) { db, err := NewMem() require.NoError(t, err) defer db.Close() topics := []thor.Bytes32{randBytes32(), randBytes32(), randBytes32()} + topics1 := []thor.Bytes32{randBytes32(), randBytes32()} addrs := []thor.Address{randAddress(), randAddress()} b := new(block.Builder).Build() @@ -118,8 +137,8 @@ func TestEventFilterScanEquivalence(t *testing.T) { bb.Transaction(newTx(tx.TypeLegacy)) k := n*4 + j receipts = append(receipts, &tx.Receipt{Outputs: []*tx.Output{{Events: tx.Events{ - {Address: addrs[k%2], Topics: []thor.Bytes32{topics[k%3]}, Data: []byte{1}}, - {Address: addrs[(k+1)%2], Topics: []thor.Bytes32{topics[(k+1)%3]}, Data: []byte{2}}, + {Address: addrs[k%2], Topics: []thor.Bytes32{topics[k%3], topics1[k%2]}, Data: []byte{1}}, + {Address: addrs[(k+1)%2], Topics: []thor.Bytes32{topics[(k+1)%3], topics1[(k+1)%2]}, Data: []byte{2}}, }}}}) } b = bb.Build() @@ -129,35 +148,17 @@ func TestEventFilterScanEquivalence(t *testing.T) { } type key [3]uint32 - crit := func(i int) *EventCriteria { return &EventCriteria{Topics: [5]*thor.Bytes32{&topics[i]}} } + crit := func(a *thor.Address, ts ...*thor.Bytes32) *EventCriteria { + c := &EventCriteria{Address: a} + copy(c.Topics[:], ts) + return c + } - // index path: same subquery shape, criteria rendered without the unary plus + // index path: the same subquery with the range scan disabled indexPath := func(f *EventFilter) []key { - var ( - sub = "SELECT seq FROM event WHERE 1" - args []any - ) - from, _ := newSequence(f.Range.From, 0, 0) - to, _ := newSequence(f.Range.To, txIndexMask, logIndexMask) - args = append(args, from, to) - var sb strings.Builder - sb.WriteString(" AND seq >= ? AND seq <= ? AND (") - for i, c := range f.CriteriaSet { - cond, cargs := c.toWhereCondition(false) - if i > 0 { - sb.WriteString(" OR") - } - sb.WriteString(" (" + cond + ")") - args = append(args, cargs...) - } - sb.WriteString(")") - sub += sb.String() //nolint:gosec // test query from fixed fragments - if f.Order == DESC { - sub += " ORDER BY seq DESC LIMIT ?, ?" - } else { - sub += " ORDER BY seq ASC LIMIT ?, ?" - } - args = append(args, f.Options.Offset, f.Options.Limit) + sub, args, err := buildEventFilterSubQuery(f, false) + require.NoError(t, err) + require.NotContains(t, sub, "+topic") rows, err := db.readDB.Query(sub, args...) require.NoError(t, err) defer rows.Close() @@ -171,16 +172,24 @@ func TestEventFilterScanEquivalence(t *testing.T) { return out } - for _, set := range [][]*EventCriteria{{crit(0)}, {crit(1), crit(2)}, {crit(0), crit(1), crit(2)}} { + sets := map[string][]*EventCriteria{ + "topic0": {crit(nil, &topics[0])}, + "two topic0": {crit(nil, &topics[1]), crit(nil, &topics[2])}, + "three topic0": {crit(nil, &topics[0]), crit(nil, &topics[1]), crit(nil, &topics[2])}, + "topic0+topic1": {crit(nil, &topics[0], &topics1[0])}, + "topic1": {crit(nil, nil, &topics1[1])}, + "mixed": {crit(nil, &topics[0]), crit(&addrs[0], &topics[1])}, + } + for name, set := range sets { for _, order := range []Order{ASC, DESC} { for _, rng := range []Range{{5, 5}, {3, 12}, {0, 100}} { for _, opt := range []Options{{0, 1000}, {2, 5}, {1000, 5}} { f := &EventFilter{CriteriaSet: set, Range: &rng, Options: &opt, Order: order} - name := fmt.Sprintf("%d/%v/%v/%v", len(set), order, rng, opt) - t.Run(name, func(t *testing.T) { - sub, _, err := buildEventFilterSubQuery(f) + t.Run(fmt.Sprintf("%s/%v/%v/%v", name, order, rng, opt), func(t *testing.T) { + require.True(t, narrowRange(f.Range)) + sub, _, err := buildEventFilterSubQuery(f, true) require.NoError(t, err) - require.Contains(t, sub, "+topic0") + require.Contains(t, sub, "+topic") evs, err := db.FilterEvents(context.Background(), f) require.NoError(t, err) @@ -188,9 +197,8 @@ func TestEventFilterScanEquivalence(t *testing.T) { for _, e := range evs { got = append(got, key{e.BlockNumber, e.TxIndex, e.LogIndex}) } - want := indexPath(f) - assert.Equal(t, want, got) - if rng.To-rng.From == 0 && opt.Offset == 0 && opt.Limit == 1000 { + assert.Equal(t, indexPath(f), got) + if rng == (Range{0, 100}) && opt == (Options{0, 1000}) { assert.NotEmpty(t, got) } }) diff --git a/logdb/types.go b/logdb/types.go index 69cda87540..7390c9b98c 100644 --- a/logdb/types.go +++ b/logdb/types.go @@ -64,26 +64,20 @@ type EventCriteria struct { Topics [5]*thor.Bytes32 } -// maxScanBlocks bounds the block span over which topic0-only criteria are evaluated by a seq -// range scan rather than the topic0 index; past this width the scan stops paying off for sparse topics. +// maxScanBlocks bounds the block span over which address-less criteria are evaluated by a seq +// range scan rather than a topic index; past this width the scan stops paying off for sparse topics. const maxScanBlocks = 100_000 -// topic0Only reports whether the criterion constrains nothing but topic0. -func (c *EventCriteria) topic0Only() bool { - if c.Address != nil || c.Topics[0] == nil { - return false - } - for _, t := range c.Topics[1:] { - if t != nil { - return false - } - } - return true +// seqBounded reports whether an index lookup for the criterion can be bounded by the seq range. +// Every event index except event_i0 has address between its topic columns and seq, so without an +// address the lookup reads every matching entry chain-wide before the seq filter applies. +func (c *EventCriteria) seqBounded() bool { + return c.Address != nil } -// toWhereCondition renders the criterion. With noTopic0Index set, topic0 is written as +topic0 so -// SQLite cannot use it for an index lookup and falls back to the seq range. -func (c *EventCriteria) toWhereCondition(noTopic0Index bool) (cond string, args []any) { +// toWhereCondition renders the criterion. With rangeScan set, topics are written as +topicN so +// SQLite cannot use them for an index lookup and falls back to the seq range. +func (c *EventCriteria) toWhereCondition(rangeScan bool) (cond string, args []any) { cond = "1" if c.Address != nil { cond += " AND address = " + refIDQuery @@ -91,11 +85,11 @@ func (c *EventCriteria) toWhereCondition(noTopic0Index bool) (cond string, args } for i, topic := range c.Topics { if topic != nil { - if i == 0 && noTopic0Index { - cond += " AND +topic0 = " + refIDQuery - } else { - cond += fmt.Sprintf(" AND topic%v = ", i) + refIDQuery + col := fmt.Sprintf("topic%v", i) + if rangeScan { + col = "+" + col } + cond += " AND " + col + " = " + refIDQuery args = append(args, removeLeadingZeros(topic.Bytes())) } }