Repository navigation
Keep v2 reads off whole-table scans found by a SQL plan audit - #215
Conversation
|
Stacked on this PR: #223 (FTS5 trigram substring search) adds migration 0012 |
…eign keys off (#218) * Merge echo duplicates into the local row, and run migrations with foreign keys off Two foreign-key hazards from the 2026-10-09 SQL plan audit (#215), both reproduced by new tests before the fix. repointLocalMessage deleted an echo-projected duplicate with a plain DELETE. A read cursor, reaction intent or read-receipt intent on it (NO ACTION) made Confirm, ReconcileConfirm and RepairStoreFailed roll back on every retry, and reactions, snapshot fences and attachments on it (CASCADE) vanished. The new mergeEchoDuplicate repoints those references and merges the child rows onto the local row in the same transaction first. The collision lookup now uses the local row's current conversation, and a missing local row leaves the echo row in place. The migration runner ran every migration in a transaction with foreign keys on, where PRAGMA foreign_keys = OFF is a no-op. A table rebuild of a parent would cascade-delete its children (or fail on a NO ACTION child) and still pass foreign_key_check. Migrations now run on one pinned connection with enforcement off from before BEGIN until after the last COMMIT, SQLite's documented procedure. Each migration still runs foreign_key_check, plus a new check that every foreign key's parent table exists. The connection is discarded if enforcement can't be confirmed back on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Order snapshot reactions by source sequence in the echo merge; harden the migration connection Addresses the independent review of #218 at 6e5942f. Blocker: when either row had a reaction snapshot fence, the merge picked the newer reaction by occurred_at_ms, which ReplaceEmbeddedReactions stamps with the write time. An older snapshot processed later on one row could replace the newer snapshot's reaction and then sit behind the merged fence, and a newer snapshot's omissions on one row did not remove reactors active on the other. With a fence, the merge now orders by (source_seq_ms, occurred_at_ms) and tombstones any reactor still active below the merged fence, as applying the newest snapshot would. Deltas keep (occurred_at_ms, source_seq_ms). A new property drives random snapshot or delta writes through the public writers onto both rows and checks that the merged row matches one message that received the same writes, before and after further writes. The SQL-level property now compares every column of the six tables and generates reactor identities, self reactions, attachment refs and errors. The review's surviving mutants (identity, self flag, remote_ref) are now killed. Migrations: the dedicated connection is always closed instead of returned to the pool, so nothing a migration leaves on it (TEMP tables, pragmas, a raw BEGIN) leaks. Each migration transaction is rolled back on every early return or panic. The missing-parent check reads only the main schema, so a TEMP table can neither hide a dropped parent nor fake a missing one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Every statement in internal/storage/sqlite (209) was run through EXPLAIN QUERY PLAN and timed on copies of the live v2 store at 1x, 30x and 10x-deep scale. This fixes the hot or growing reads that visited a whole table: - /api/status freshness walked every message (1,679 statements, 0.8 s every 30 s today, 19 s at 30x). It now reads per-account MAX seeks on a new messages(account_id, direction, occurred_at_ms) index through a count-free PlatformLatest; MessageCount, ConversationCount, LatestTimestamp and PlatformStats are per-account aggregates. - Keyset pages compare (occurred_at_ms, message_id) as a row value, so the index range bounds the page; the OR form bounded only conversation_id. - An empty-query search (MCP get_messages) reads a new messages_time_idx from its newest end instead of sorting the table; unscoped substring search keeps its sequential scan (NOT INDEXED) until FTS. - The sender filter selects identity IDs in a subquery so messages_sender_time_idx is seeked instead of walked whole. - The cross-account conversation list is one statement over both arms of conversations_recency_idx instead of loading every conversation into Go. - Rebind lookups start from conversation_participants; MessageHasReadCursor seeks the message's conversation; outbox state per rendered message uses a new partial outbox_local_message_idx. Migration 0011 read_path_indexes adds the three indexes and changes no rows. Plans are pinned in read_plans_test.go; differential properties compare each rewrite with the statement it replaced on random stores. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…entials Review of the plan audit found that a newer `openmessage read`/`status` or MCP client process would apply pending migrations (0011's index builds among them) underneath the running daemon. A migration holds SQLite's write lock for its whole run, and the daemon's writers give up after the 5 s busy timeout, so inbox appends could be dropped. Read clients now open the store with sqlite.OpenWithoutMigrating: it never creates or migrates the store, checks the ledger in a read-only transaction (no write reservation), and refuses a store with pending migrations with "quit and reopen the OpenMessage app". Only the daemon (first step of its v2 stack), repair and cutover migrate. Also from the review: - an account-filtered listing restates direction's CHECK so the account/direction/time index bounds a date window instead of a forced scan; - the newest-times pin requires both direction seeks; - differential properties compare whole rows, and the stats property covers edited/deleted rows and a bridge key only Go treats as blank; - comments state what the archived arm and the old aggregates actually read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 2 found the last migrating open a non-owner could reach: `openmessage repair google-idspace` without --apply skips the daemon probe and instance lock, yet opened the store with the migrating Open. A dry run now uses OpenWithoutMigrating (only --apply, which holds the lock, migrates), and --reference reads a migrated temporary copy, so a user's older backup is never rewritten. OpenWithoutMigrating connects with mode=rw, so a store removed after the existence check is not recreated. Rebased onto #218 and #229 and audited the statements they added: - outbox_local_message_idx now leads with local_message_id, so repair's two open-send lookups (local_message_id alone) seek it as well as the per-row outbox state (local_message_id and account_id); - readCursorDeviceOutside, run on every echo merge, seeks the message's own conversation instead of scanning read_cursors (the composite FK puts any cursor naming the message there), like MessageHasReadCursor; - every merge step already seeks an index (pinned by EXPLAIN in review); - #218's rebuild fixture recreates the two new messages indexes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2deda59 to
bb35860
Compare
|
Merged at reviewed head bb35860 (squash 29a9a8e). Gates: 🤖 Generated with Claude Code |
Summary
#210 found two inbox reads doing
SCAN inbox. This audits every SQL statement ininternal/storage/sqlite, plus the v2 read paths that drive them (internal/v2read,/api/status, the MCP tools), for plans that read a whole table that grows with history, and fixes the ones on hot or growing paths.Where something was found:
/api/statusfreshness walked every message every 30 s: 1,679 statements, 0.8 s on today's store, 19 s at 30×. Its keyset cursor also made the walk quadratic within a thread.get_messages, a sender filter that walked a whole index, a conversation list loaded entirely into Go, and three rebind lookups whose default-statistics join order started from the account side.Method
internal/migration/validate.go, which runs once peropenmessage migrate.ANALYZE. The app never runs ANALYZE, so default-statistics plans are the ones that matter, but the fixes hold either way.Open(), then all 214 statements were rerun on six stores. That catches a new index changing some other statement's plan.Timings were taken on a heavily loaded machine (load average 40–170). Read the ratios, not the absolute values.
Partial indexes
inbox_dedupe_uqdedupe_key = ?does not imply<> ''):SCAN inboxper duplicate frame. Fixed by #210.messages_sender_time_idxi.canonical_value. With no index leading on that column, the planner walked the whole index. Fixed here.inbox_unprocessed_idx,message_attachments_blob_hash_idxoutbox_expired_lease_idxoutbox_due_idx (state=?), which reads the same in-flight rows; with ANALYZE it picks the partial index. Fine either way.devices_current_local_uqaccounts_remote_uq,devices_remote_uq,person_primary_identity_uq,outbox_send_again_of_idxFixed
Median ms at 1× / 30× / deep 10×:
/api/statusfreshness (v2PlatformStats)get_messages)identitiesfor the canonical value: 3.2k rows today)/api/conversations, every ~5 s per open UIopenmessage repair, per planned rowSCAN read_cursorsSCAN read_cursors+ sortopenmessage repair, per planned rowSCAN outboxoutbox_local_message_idx (local_message_id=?)How:
MAXseeks on a newmessages_account_direction_time_idx, through a count-freePlatformLatestthat the v2 read source implements./api/statususes it when available.PlatformStats,MessageCount,ConversationCountandLatestTimestampbecome per-account aggregates. The counts are one coveringCOUNT(*)each (5.6 / 125 / 47 ms) and run for the CLI and MCP status,/api/diagnosticsand the Google pull-health counter (ConversationCount("sms")). Before, all four loaded each account's whole conversation list;PlatformStatsandMessageCountthen paged through every message, andLatestTimestampread each conversation's newest message.(occurred_at_ms, message_id)as a row value, which SQLite turns into an index range. The old OR form bounded onlyconversation_id. Both columns are NOT NULL (STRICT primary keys reject NULL; checked), so the two forms select the same rows.messages_time_idxfrom its newest end. Filtered by account (no current caller does),direction IN ('incoming', 'outgoing')restates the column's CHECK somessages_account_direction_time_idxbounds a date window per direction.messages(account_id, sender_identity_id) → identities(account_id, identity_id)makes the dropped account match redundant.NOT INDEXED. With the time index present, the planner would otherwise walk it and fetch rows out of order: 3.4 s versus 0.45 s for a rare term at 30×.conversations_recency_idx. The unarchivedIS NULLprefix is already in recency order, so that arm stops at the limit. The archived>= 0range (the column's CHECK makes that every non-NULL value) is ordered by archive time first, so that arm reads and sorts every archived conversation. The cost is the limit plus the archived count (zero archived conversations on the live store), never the whole table unless every conversation is archived.conversation_participants. A CROSS JOIN pins the roster order; the peer and group lookups drive from the identity's participant rows. The group roster comparison is unchanged: a match must contain every wanted identity, so only groups holding the first one can match.outbox_local_message_idx (local_message_id, account_id, created_at_ms, outbox_id)serves the per-row state lookup (both bound, already in the newest-first order) and repair's open-send checks (local_message_idalone).read_cursorshas no index onlast_read_message_id, but the composite FK(conversation_id, last_read_message_id) → messages(conversation_id, message_id)puts any cursor naming a message in that message's own conversation.MessageHasReadCursorandreadCursorDeviceOutsidetherefore seekread_cursors_conversation_idxthere.Migration 0011
read_path_indexesThree indexes, no row changes. On copies, the migration itself (ledger
execution_ms) took 1.5 s on the live store, 4.7 s at deep 10× and 20 s at 30×; the wholeOpen()took 2.1 / 6.1 / 26.5 s. Integrity was ok each time.Read clients never migrate (from review round 1)
The reviewer showed that a newer
openmessage read/statusor MCP client process would apply pending migrations underneath the running daemon. A migration holds SQLite's write lock for its whole run, and the daemon's writers give up after the 5 s busy timeout, so inbox appends could be dropped (reproduced with a held migration transaction). This was true for any migration on main, but 0011's index builds made it concrete. Read clients now open the store withsqlite.OpenWithoutMigrating:ErrMigrationPending, "quit and reopen the OpenMessage app").Round 2 found one more non-owner path.
openmessage repair google-idspacewithout--applyskips the daemon probe and instance lock, yet opened the store with the migratingOpen. Now:OpenWithoutMigrating, and only--apply, which has checked the daemon is down and holds the instance lock, migrates;--referencereads a migrated temporary copy, so a user's older backup is never rewritten;OpenWithoutMigratingconnects withmode=rw, so a store removed after the existence check is not recreated.It is otherwise an ordinary read-write connection: like any SQLite connection that closes last, it may checkpoint a WAL nobody else has open, which changes no row. #166's
OpenReadOnlyis the fuller version of this (amode=roconnection, schema-range reads) and can replace it at the same call sites when #166 lands.The migrating opens left are the daemon (the first step of its v2 stack, before v2 ingest exists),
repair --applyand cutover. The daemon's ownership is assumed, not locked: a second daemon started by hand (serve --webbeside the app) would still migrate before failing on the busy port, as on main today. The runbook forbids that shape, and locking daemon startup before it opens stores is #242.A full rerun of every statement found one other plan change.
FindMessageContentDuplicate(per Google message) now seeks the new account/direction/time index on three equalities instead of the conversation index on two. Both read only the rows at that exact millisecond, at the same speed. It's pinned as "never scans".Not fixed, and why
LIKE(0.45–1 s at 30×); needs FTS5 (follow-up)openmessage repairstatements;openmessage migratevalidationInvariants (stated, and tested for all inputs the generators reach)
Keyset. For every cursor and limit, the row-value cursor returns exactly what the OR-form cursor returned, in the same order. Paging a thread visits every message once, newest first. Differential property against the old SQL, kept verbatim in the test.
Search. For every filter combination (term, account, conversation, sender phone, since, until), the new statement returns the same messages in the same order as the old one. Same differential style, over stores where phone numbers repeat across accounts and kinds and some senders are NULL.
Rebind. The roster, sole-peer and peer-set lookups equal the replaced queries and the replaced Go group scan for every account, identity and roster in random stores.
Conversation list. For every limit, it equals a full sort of all conversations (archived included, ties broken by ID), truncated.
Aggregates:
occurred_at_ms(0 when none);PlatformLatest=PlatformStatsminusCount.The read-cursor check equals counting cursors; the outbox state equals the newest row by
(created_at_ms, outbox_id).Migration. It applies to blank and to pre-0011 stores with rows. Earlier ledger rows and all rows are unchanged, the checksum is pinned, and a reopen applies nothing.
Plans. Every fixed statement's EXPLAIN QUERY PLAN is pinned (
read_plans_test.go), including the one deliberate scan, and both direction seeks of the newest-times read.Read clients. A store with pending migrations is refused and left byte-identical; a current store opens; a newer one is refused; a missing file is not created. The open never waits for, or blocks, the owner's write lock.
readand the MCP client refuse a store one migration behind and apply nothing.Mutation check: 33 planted bugs, all caught by these tests (
mutants.txtrecords which test failed for each; a mutant that doesn't build counts as not caught). They include the review's surviving candidates: an outgoing seek without its direction, and a search projection that blanks bodies (now caught because the differentials compare whole rows). They also cover a client ledger check that takes the write lock, and both call sites reverting to the migratingOpen.Coordination
migrations.go, the file name, and the version-count lines every migration PR edits (*_test.go,internal/migration/validate.goandtransform_test.go).FindDirectConversationBySolePeerandFindGroupConversationByPeerSetintoList*variants with the same SQL. Whichever lands second carries the other's change over: here that's the two query constants, which stay exact when all matches are returned./usr/local/bin/openmessage, which the MCP server runs (it is currently a July 21 build). Until the two match, CLI and MCP reads, and repair dry runs, refuse the store with a clear error; nothing migrates under the running daemon.Test plan
go test ./internal/storage/sqlite ./internal/v2read ./internal/web ./internal/db ./internal/tools ./internal/ingest ./internal/v2wire ./internal/migrationgo test ./cmd -run 'Status|Read|Migrate'-race(sqlite package: 150 s locally on a loaded machine at 60 cases; now 30)go test ./cmd -run 'TestOpenCommandReadSource|TestRunServeMCP|Client|Status'go test ./cmd -run 'TestRepair'Evidence (local):
~/reviews/openmessage-sqlite-plan-audit-2026-10-09/. It holdsAUDIT.md, the per-statement appendix, the raw results for all twelve store/label runs, the harness, the scale scripts and the mutants.🤖 Generated with Claude Code