Repository navigation
perf(db): pin GetChannels and CountFloodAdvertsForNode to their indexes (issue #100) - #107
Merged
Merged
Conversation
Without sqlite_stat1 SQLite plans GetChannels on idx_transmissions_payload_type (GROUP BY via temp B-tree) and, when that index is newer than idx_tx_channel_hash, the correlated sample subquery too. With statistics it picks idx_tx_channel_hash for both. ANALYZE holds the write lock for ~65 s on a 5 GB database (PR #106), so pin the statistics plan instead (issue #100): - GetChannels: INDEXED BY idx_tx_channel_hash on the outer scan and the sample subquery, via channelsSQL(v3, regionPlaceholder, pinned). A unary + on payload_type is not an option: it breaks the partial index's "payload_type = 5" implication and SQLite falls back to full scans. INDEXED BY fails at prepare time when the index is missing or unusable; GetChannels then runs the unpinned query (logged once). - CountFloodAdvertsForNode: +payload_type keeps it on idx_transmissions_from_pubkey, as nodeAdvertScanSQL does. Tests: EXPLAIN QUERY PLAN without statistics in both index creation orders (red on master) and with ANALYZE (pinned plan == SQLite's own choice), raw-row and GetChannels result equivalence pinned vs unpinned on mixed synthetic data (v2 and v3 schemas, 7 region inputs), and /api/channels answering 200 without the index. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UxGNmBfShy5AxAovsFnx7V
dborup
marked this pull request as ready for review
September 28, 2026 14:16
This was referenced Sep 28, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Pins
GetChannelstoidx_tx_channel_hashandCountFloodAdvertsForNodetoidx_transmissions_from_pubkey. Both then get the plan SQLite picks when planner statistics exist, but without runningANALYZE. Decision issue: issue #100. This replaces the ANALYZE port in PR #106, which is parked.Server only (
cmd/server, read-only). No changes tocmd/ingestor, the schema or the workflows.Problem, and why pinning instead of ANALYZE
sqlite_stat1(production: neither binary runsANALYZE), SQLite plansGetChannelsonidx_transmissions_payload_type, with a temp B-tree forGROUP BY. When the payload_type index is the newer of the two, the correlated sample subquery also goes through it. It then visits every GRP_TXT row once per channel.idx_tx_channel_hashfor the outer scan (in channel_hash order, so no GROUP BY B-tree) and for the subquery (channel_hash=?).GetChannels(all): cold 6.76 → 4.42 s, warm 289 → 231 ms.GetEncryptedChannels: plan unchanged.Mechanism
GetChannels:INDEXED BY idx_tx_channel_hashplus a fallbackchannelsSQL(v3, regionPlaceholder, pinned)builds all three variants (no region, v3 region, v2 region).pinnedaddsINDEXED BY idx_tx_channel_hashto bothtransmissionsreferences: the outertand the subquery'st2.+(the feat(nodes): Recent Adverts split into flood / zero-hop / mixed with per-class counts #97 technique):idx_tx_channel_hashis partial (WHERE payload_type = 5). SQLite uses a partial index only if the query's WHERE clause contains that predicate, and+t.payload_type = 5no longer counts. Measured on SQLite 3.46.0 (modernc): the plan becomesSCAN t+SCAN t2 USING INDEX idx_transmissions_first_seen, i.e. full scans (mutation M7 below).INDEXED BYfails at prepare time, before any row is read, when the index is missing or unusable.GetChannelsthen runs the unpinned query (the pre-PR SQL). The first fallback per DB handle is logged, andchannelsPinFallbackscounts them.schemaFlagis monotonic ("true is permanent"), which fits ADD-only columns. An index can be dropped, so a flag alone would still need the error fallback, while the error fallback alone covers missing, dropped and differently-defined indexes. It is the simpler of the two.'enc_%%'is now passed throughSprintflike the region variants, so SQLite receives'enc_%'. As a LIKE pattern,%%and%match the same strings; the equivalence test covers it.CountFloodAdvertsForNode:+payload_typefloodAdvertCountSQLwithfrom_pubkey = ? AND +payload_type = ?, the same technique asnodeAdvertScanSQL(feat(nodes): Recent Adverts split into flood / zero-hop / mixed with per-class counts #97).idx_transmissions_from_pubkeyis not partial, so the+is safe.idx_transmissions_payload_typewhenever that index is newer than the from_pubkey index, and then visits every ADVERT in the database.+payload_type = ?matches exactly the rowspayload_type = ?does.GetEncryptedChannels: unchangedLIKE 'enc_%'cannot use the partial index, and even withANALYZESQLite keepsidx_transmissions_payload_typeon the synthetic data (same finding as the PR #106 review). Nothing to pin.Plan tests (
channels_index_pin_test.go)pinTestDBbuilds the production indexes (ingestor +internal/dbschema) in two creation orders: the ingestor's order (payload_type first) andpayloadTypeLast.setupTestDBcannot be used for this: it has a non-partial channel_hash index and no payload_type index.Without statistics (
TestChannelsSQL_PlanWithoutStats): 12 cases (v3/v2 × both orders × all / 1 region / 2 regions). Assert: outerSEARCH t USING INDEX idx_tx_channel_hash, subquerySEARCH t2 USING INDEX idx_tx_channel_hash (channel_hash=?), no payload_type index, noTEMP B-TREE FOR GROUP BY.Master is red in all 12 (run against master's SQL verbatim):
With statistics (
TestChannelsSQL_PlanWithStats): 6,000 skewed transmissions (55 % GRP_TXT, ~70 % of themenc_*, 7 plaintext channels), thenANALYZE. In all 12 cases the pinned plan is identical to the plan SQLite chooses for the unpinned query, and it is theidx_tx_channel_hashplan. The pin does not make the plan worse once statistics exist. (Master is green here too, as expected: with statistics it already chooses this plan.)TestFloodAdvertCountSQL_Plan: both orders × with/without statistics give exactlySEARCH transmissions USING INDEX idx_transmissions_from_pubkey (from_pubkey=?), with no sort. Master is red inpayloadTypeLastwithout statistics (SEARCH transmissions USING INDEX idx_transmissions_payload_type (payload_type=?)).Result equivalence
TestGetChannels_PinnedMatchesUnpinned, run for v3 and v2 × both orders.AAR," cph ",CPH, NULL, empty and lowercase IATA),enc_*/ENC_*/encX*hashes, NULLchannel_hashand NULLdecoded_json, undecodable JSON and JSON withouttext,channel_hashon other payload types,first_seenties within and across channels, three timestamp formats, transmissions without observations, and observations of unknown or NULL observers.channelsSQL(pinned)equal those ofchannelsSQL(unpinned)for all/1/2 regions (≥5 channel rows each).GetChannelsoutput:reflect.DeepEqualfor 7 region inputs ("",AAR,cph,AAR,CPH," aar , osl ",sto,ZZZ), first with the index present (0 fallbacks), then afterDROP INDEX idx_tx_channel_hash(7 fallbacks, same output).TestFloodAdvertCountSQL_MatchesUnpinned: identical rows, with and without the+, for every node in the random data.TestGetChannels_MissingIndexFallsBack:setupTestDBhas noidx_tx_channel_hash./api/channels,?region=SJCand?includeEncrypted=trueanswer 200 with#test, and the fallback counter shows the pinned query was tried first.Mutations (7, all killed)
hint = "")idx_transmissions_channel_hash)payloadTypeLast: subquery on payload_type)+on the wrong column (+from_pubkey = ? AND payload_type = ?)GetChannelsruns the unpinned SQL first+payload_type = 5instead ofINDEXED BYSCAN t+SCAN t2 USING INDEX idx_transmissions_first_seen), PinnedMatchesUnpinned, MissingIndexFallsBackLocal performance (synthetic, not staging)
Hardware: KVM guest, 4 vCPU Intel Xeon @ 2.10 GHz, 15 GB RAM, virtio disk (ext4), Linux 6.18. SQLite 3.46.0 (modernc.org/sqlite v1.34.5). Opened with the server's DSN (
mode=ro, default 2 MB page cache).Method:
echo 3 > /proc/sys/vm/drop_cachesinside the guest plus a new connection, median of 3. The host's cache is outside my control.Synthetic DB: 1.25 GB, no
sqlite_stat1, indexes in the ingestor's order.enc_*over 60 hashes and 79,842 are plaintext over 12 channels.How to read this:
GetChannelswithout region is ~13 % faster; with region between 13 % faster and 3 % slower.GetChannelswithout region is slower, 0.54–0.94 s → 1.06–1.30 s. With region, the difference is within the noise (±25 % between repeats).blockdev --setrais not permitted here): channel_hash order gives random row lookups where rowid order gets sequential readahead.CountFloodAdvertsForNodeis unchanged when the from_pubkey index already wins (ingestor order).GetChannelsvisits the same GRP_TXT rows as before, now in index order with no GROUP BY sort, plus onechannel_hash=?lookup per channel. No new work in any hot path. Results stay cached for 60 s as before.Staging test plan
sqlite_stat1is absent andidx_tx_channel_hashexists (SELECT sql FROM sqlite_master WHERE name='idx_tx_channel_hash'). RecordEXPLAIN QUERY PLANfor the channel query with and without region (expect payload_type + GROUP BY B-tree)./api/channels,?region=AAR,?region=CPHand?region=AAR,CPHcold (first call after restart, or a dropped cache on the DB copy) and warm (the second call is served from the 60 s cache; measure after it expires or on the DB copy).GetChannels: idx_tx_channel_hash not usable./api/nodes/{pubkey}for a heavy advertiser:flood_advert_count_7dunchanged. The query only runs when the upstream#2073scan does not supply it.Checks
go vet ./...in cmd/server: clean.gofmt -lon touched files: clean (some untouched files on master are not gofmt-clean; unchanged).go test -timeout 20m -race -count=1 ./...in cmd/server: ok (766 s). Includes the read-only invariant; its 4 tests (readonly_invariant_test.go) also pass on their own.map[string]interface{}(the existing return type ofGetChannelsis unchanged).🤖 Generated with Claude Code
https://claude.ai/code/session_01UxGNmBfShy5AxAovsFnx7V
Generated by Claude Code