Skip to content

feat: add additional event indexes for improved query performance - #1690

Closed
moglu2017 wants to merge 3 commits into
masterfrom
moglu/optimization_slow_api
Closed

moglu2017 wants to merge 3 commits into
masterfrom
moglu/optimization_slow_api

Conversation

@moglu2017

Copy link
Copy Markdown
Contributor

Description

Add additional event indexes for improved query performance.

Symptom

POST /logs/event with a narrow block range and one or more topic0-only criteria (no address) takes seconds and hits the 10s API timeout on a fresh node. Example (single block, 6 topic0 criteria, limit: 1000, 10 rows returned):

{
  "range": {"unit": "block", "from": 25769599, "to": 25769599},
  "criteriaSet": [
    {"topic0": "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"},
    {"topic0": "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925"},
    {"topic0": "0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c"},
    {"topic0": "0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65"},
    {"topic0": "0xda9fa7c1b00402c17d0161b249b1ab8bbec047c5a52207b9c112deffd817036b"},
    {"topic0": "0xc6a377bfc4eb120024a8ac08eef205be16b817020812c73223e81d1bdb9708ec"}
  ],
  "order": "asc",
  "options": {"offset": 0, "limit": 1000}
}

Root cause

The generated subquery is

SELECT seq FROM event
WHERE seq >= ? AND seq <= ?
  AND ((topic0 = (SELECT id FROM ref WHERE data=?)) OR … )
ORDER BY seq ASC LIMIT ?, ?

EXPLAIN QUERY PLAN:

MULTI-INDEX OR
  INDEX 1..n: SEARCH event USING COVERING INDEX event_i1 (topic0=?)
USE TEMP B-TREE FOR ORDER BY

event_i1 is (topic0, address) — effectively (topic0, address, seq) since seq is the rowid. seq is only ordered inside each (topic0, address) group, so with address unconstrained the planner cannot bound seq in the index. It positions on topic0, reads every entry for that topic0 chain-wide, filters each by seq, then re-sorts. Cost is O(total rows with that topic0), independent of the block range. The rows actually in the requested range are typically tens.

SQLite cannot skip-scan the middle address column (skip-scan only skips leading columns), and thor never runs ANALYZE, so the planner always prefers the index over the rowid range. sqlite_stat1 would not fix it either — range selectivity is not estimated without STAT4.

Proposed fix

Add a single-column index:

CREATE INDEX IF NOT EXISTS event_i6 ON event (topic0);

Type of change

  • New feature (non-breaking change which adds functionality)

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • New and existing E2E tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules
  • I have not added any vulnerable dependencies to my code

@moglu2017
moglu2017 requested a review from a team as a code owner October 20, 2026 08:33
@codecov-commenter

codecov-commenter commented Oct 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.92308% with 6 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
logdb/logdb.go 76.92% 3 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

@otherview

Copy link
Copy Markdown
Member

What happens to topic0 + topicN queries that don't filter on address once event_i6 exists? Take {topic0: Transfer, topic1: wallet}, i.e. "all Transfers sent by wallet X". On master, SQLite uses the covering event_i2 (topic1=? AND topic0=?) and jumps straight to that wallet's rows. Without stats, could the planner now pick event_i6 (topic0=? AND rowid range) instead? Then it would read every Transfer row in the range and do a table lookup on each one to check topic1.

I tried it locally, and that seems to be what happens. On a synthetic DB with 3M events (80% Transfer), "Transfers from wallet X" over the whole chain went from 0.14 ms to 710 ms. That cost grows with the total Transfer count, so would it time out on mainnet?

This test pins the expected index for each criteria shape. On the current branch, topic0+topic1, topic0+topic2 and topic0+topic3 all fail for narrow, wide and nil ranges:

// Pins the index chosen for each criteria shape so new indexes can't silently steal plans.
func TestEventQueryPlanPerCriteriaShape(t *testing.T) {
	db, err := NewMem()
	require.NoError(t, err)
	defer db.Close()

	addr := thor.BytesToAddress([]byte("a"))
	t0, t1, t2, t3 := randBytes32(), randBytes32(), randBytes32(), randBytes32()

	shapes := []struct {
		name      string
		criteria  EventCriteria
		wantIndex string
	}{
		{"topic0", EventCriteria{Topics: [5]*thor.Bytes32{&t0}}, "event_i6"},
		{"address", EventCriteria{Address: &addr}, "event_i0"},
		{"address+topic0", EventCriteria{Address: &addr, Topics: [5]*thor.Bytes32{&t0}}, "event_i1"},
		{"topic0+topic1", EventCriteria{Topics: [5]*thor.Bytes32{&t0, &t1}}, "event_i2"},
		{"topic0+topic2", EventCriteria{Topics: [5]*thor.Bytes32{&t0, nil, &t2}}, "event_i3"},
		{"topic0+topic3", EventCriteria{Topics: [5]*thor.Bytes32{&t0, nil, nil, &t3}}, "event_i4"},
		{"address+topic0+topic1", EventCriteria{Address: &addr, Topics: [5]*thor.Bytes32{&t0, &t1}}, "event_i2"},
	}
	ranges := map[string]*Range{
		"narrow": {From: 100, To: 100},
		"wide":   {From: 0, To: 25_000_000},
		"nil":    nil,
	}

	for _, s := range shapes {
		for rname, r := range ranges {
			t.Run(s.name+"/"+rname, func(t *testing.T) {
				c := s.criteria
				query, args, err := buildEventFilterSubQuery(&EventFilter{
					Range:       r,
					CriteriaSet: []*EventCriteria{&c},
					Options:     &Options{Limit: 1000},
				})
				require.NoError(t, err)

				rows, err := db.readDB.QueryContext(context.Background(), "EXPLAIN QUERY PLAN "+query, args...)
				require.NoError(t, err)
				defer rows.Close()
				var plan []string
				for rows.Next() {
					var id, parent, notUsed int
					var detail string
					require.NoError(t, rows.Scan(&id, &parent, &notUsed, &detail))
					if strings.Contains(detail, "SEARCH event") {
						plan = append(plan, detail)
					}
				}
				require.NoError(t, rows.Err())
				assert.Contains(t, strings.Join(plan, "\n"), "INDEX "+s.wantIndex+" ")
			})
		}
	}
}

Would one of these work?

  • Use INDEXED BY event_i6 only for topic0-only criteria, which means building one sub-select per criterion and combining them with UNION.
  • Seed sqlite_stat1 so the planner knows topic0= alone matches far more rows than (topic1, topic0)=.
  • Drop the index and write +topic0 for narrow topic0-only ranges.

Whichever approach you take, could this test go in with it so the plans stay pinned?

@libotony

Copy link
Copy Markdown
Member

Close this in favor of #1693

@moglu2017 moglu2017 closed this Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants