Repository navigation
Conversation
Silence detection reads one codec's receipt times inside a window, and the only index on received_at_ms was partial (unprocessed frames), so every baseline read scanned the whole inbox and sorted it. Migration 0011 adds inbox(codec, received_at_ms), which answers that read from the index alone. With the index present SQLite scanned all of it for the incremental InboxReceiptsAfterRow refresh (to skip the GROUP BY sort), so that query now pins the rowid range with NOT INDEXED, and the full rescan steps through the index per codec instead of reading every row. The AppendInbox dedupe lookup restates the partial unique index's condition so it uses inbox_dedupe_uq instead of scanning the inbox. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Migration numbering: #215 (SQL plan audit) also adds a migration numbered 0011 ( No statement overlap: #215 leaves the inbox dedupe lookup and the receipts reads to this PR and lists them as fixed here. |
|
Migration numbering: #223 (FTS5 trigram substring search, stacked on #215) adds migration 0012 |
|
Migration numbering update: #215 merged to main (29a9a8e) and took 0011 (
Two more things changed on main:
🤖 Generated with Claude Code |
Summary
InboxReceiptsBetween(silence baselines ininternal/freshness, and #199's gap walks) ranSCAN inboxplus a temp B-tree sort, because the only index onreceived_at_msis partial (WHERE processed_at_ms IS NULL). The inbox never sheds rows, so that cost grows for as long as the store exists. This PR:inbox_codec_received:CREATE INDEX inbox_codec_received_idx ON inbox(codec, received_at_ms). Production passes one codec per platform (cmd/serve.gomapsgoogle.protobuf,whatsapp.eventandsignal.jsonrpcone-to-one), so the baseline read becomes a covering-index range search, already in receipt order.InboxReceiptsAfterRowkeeps its rowid range. I checked its GROUP BY as asked, and the index alone would have regressed it. Offered the new index, SQLite scans the whole index to skip the GROUP BY sort, so the incremental refresh behind/api/status(recomputed at most every 30 s while the app polls) went from a rowid range search over a few new rows to a scan of every row. That's 0.02 → 50 ms at 1M rows, on every refresh. The incremental form now readsFROM inbox NOT INDEXED WHERE rowid > ?; SQLite still allows the rowid lookup underNOT INDEXED. The full rescan (afterRowID <= 0: every 6 h, on the first refresh after the daemon starts, and on everyopenmessage statusrun) now walks the index per codec (a recursive-CTE skip-scan) instead of reading every table row.AppendInbox's dedupe lookup was also a full scan, found by the same planner check.inbox_dedupe_uqis partial (WHERE dedupe_key <> ''), andWHERE account_id = ? AND dedupe_key = ?with a bound key can't prove that condition, so SQLite scanned the inbox for every re-delivered frame. A history catch-up that re-fetches a frame already on file takes this path (history_dedupedininternal/ingest/sink.go), so re-running a window backfill paid one full scan per duplicate. The lookup only runs for a nonempty key, so it now addsAND dedupe_key <> ''(same result) and uses the unique index. Live counters since this morning's restart show 0 dedupes, so today the cost is per catch-up, not continuous.The query texts moved into package constants (
inboxReceiptsAfterRowQuery,inboxLatestReceiptByCodecQuery,inboxReceiptsBetweenQuery,inboxDedupeLookupQuery) so tests can pin their plans. The10 → 11edits in the migration-count assertions and theinternal/migration/validate.gochecksum pin follow the pattern #196 uses.Evidence: copies of the live store
Measured on a read-only
cpof~/Library/Application Support/OpenMessage/v2/store.sqlite3(+-wal/-shm; the WAL hash was identical before and after the copy, andintegrity_check= ok). The live store was never opened. Store facts: schema v10, 30,254 inbox rows (Google 9,155, Signal 13,943, WhatsApp 7,156), 37 MB table, nosqlite_stat1. To size growth I rebuilt the inbox at 10× and 33× (302,540 and 998,382 rows) by repeating it back in time, inserted in receipt order so rowids track time as they do live, with full payloads (pre-#196 worst case). Each query ran through the repo's own driver (modernc.org/sqlite v1.44.3 = SQLite 3.51.2) via a throwaway test. Times are medians of 15 warm runs; the machine's load average was about 150–200 from other sessions, so treat absolute times as upper bounds.InboxReceiptsBetween)InboxReceiptsAfterRowincremental (each status refresh)InboxReceiptsAfterRow(0)full rescanAppendInboxdedupe lookup (each re-delivered frame)ListInboxByCodecSince45 d (not on main)EXPLAIN QUERY PLAN, before → after (identical at every scale):InboxReceiptsBetween(1 codec)SCAN inbox/USE TEMP B-TREE FOR ORDER BYSEARCH inbox USING COVERING INDEX inbox_codec_received_idx (codec=? AND received_at_ms>? AND received_at_ms<?)InboxReceiptsAfterRow, incrementalSEARCH inbox USING INTEGER PRIMARY KEY (rowid>?)/ temp B-tree for GROUP BYSCAN inbox USING COVERING INDEX inbox_codec_received_idx)InboxReceiptsAfterRow(0)SEARCH … (rowid>?)over every row / temp B-treeSEARCH … COVERING INDEX inbox_codec_received_idx (codec>?)and(codec=?)per codec, plus oneMAX(rowid)seekSCAN inboxSEARCH inbox USING INDEX inbox_dedupe_uq (account_id=? AND dedupe_key=?)ListInboxByCodecSinceSCAN inbox/ temp B-treeSEARCH … inbox_codec_received_idx (codec=? AND received_at_ms>?)/TEMP B-TREE FOR LAST TERM OF ORDER BYUnprocessed, by-id lookups, mark-processedUPDATEWith
ANALYZEstatistics present (the store never runs ANALYZE; checked at 302k rows), every plan above stays the same.Index shape.
(codec, received_at_ms): 880 KiB at 30k rows, 9.0 MB at 302k, 29.9 MB at 998k (about 30 bytes a row). I also measured(codec, received_at_ms, inbox_id). It removes #191's tie-break sort (15.6 → 12.2 ms at 30k) but is 2.1× the size (1,880 KiB at 30k), sinceinbox_idis 32 hex characters, and nothing on main needs it.Migration at startup
The daemon opens and migrates the v2 store in
newV2Stackbefore its HTTP server starts. The app polls/api/statusfor up to 30 s (BackendManager.startHealthCheck), so the migration delays readiness by its own length and nothing more.applyMigrationruns in aBEGIN IMMEDIATEtransaction, and runsPRAGMA foreign_key_checkafter the SQL.Open()with 0011 pendingCREATE INDEXalone (3 runs)foreign_key_checkalonebusy_timeout(5 s). That's well clear at today's 30k rows. Extrapolating the timings above, it would take somewhere past 1.2M–1.7M unpruned rows on this machine.Deploy lockstep (same as #196).
serve --mcp-stdio,readandstatusopen the v2 store withsqlite.Open, which migrates. After the app's daemon applies 0011, an older/usr/local/bin/openmessage(the one~/.mcp.jsonruns) fails v2 reads withdatabase schema version 11 is newer than supported version 10. Install the CLI together with the app. If the CLI is updated first, it migrates the live store under the running v10 daemon. That daemon keeps running, though its unchangedInboxReceiptsAfterRowthen scans the new index on each refresh (about 1 ms at today's size), and its next restart fails the same way.Coordination
#196 (
inbox_payload_retention), #201 (remote_idspace_epochs) and #166 (outbox_expiry) each also add a migration numbered 0011. Whichever merges after this one renumbers to the next free number, re-pinsinternal/migration/validate.go, and bumps the count assertions; I'll comment on each. Checked against #196 specifically:integrity_checkok).inbox_payload_prune_idx.UPDATEand pruning touch no column of the new index, so they cost no index maintenance.#191's
ListInboxByCodecSince(414 → 18 ms at 1M rows) picks up the index with no change.Invariants (stated, then tested)
InboxReceiptsBetween(codecs, from, to)returns exactly the receipts of rows whose codec is listed and whosereceived_at_mslies in[from, to]. They come back ascending, with duplicates kept.InboxReceiptsAfterRow(after)returns two things:rowid > after(every row whenafter <= 0), with codecs that have no such row left out;max(after, largest such rowid).inbox_codec_received_idxdropped. The index changes plans, not results.inbox_idand adds no row.Tests:
TestInboxActivityQueriesMatchReferenceProperty: 80testing/quickcases with a fixed seed. Frames are appended out of time order, with many tied receipts, random windows and codec subsets, and re-deliveries. Each case is checked against a Go reference, against the other query form, and with the index dropped.TestInboxQueryPlansAvoidFullScanspins the plans above.TestInboxCodecReceivedMigrationAppliesToBlankAndExistingV10Databasecovers a blank store and a populated v10 store: rows unchanged, index columns and order, pinned checksum, idempotent reopen.TestInboxActivityQueriesOnEmptyInbox.NOT INDEXED, dropdedupe_key <> '', MAX→MIN in the rescan, sendafter=1down the rescan path, drop the rescan'sIS NOT NULLguard, swap the index columns, and dropORDER BY.Ran (targeted,
GOWORK=off):go test ./internal/storage/sqlite/ ./internal/migration/ ./internal/freshness/ ./internal/ingest/,go test ./cmd/ -run 'Silence|Status|Freshness|Inbox|V2Stack|Migrat', andgo test ./internal/web/ -run 'Silence|Status|Freshness'. All passed.Evidence (raw matrices, the ANALYZE run, the #196 combination, migration timing, mutant script, scale script, diagnostic test):
~/reviews/openmessage-inbox-codec-index-2026-10-09/.🤖 Generated with Claude Code