Repository navigation
Conversation
…s say it pays A v2 message search narrowed by conversation or sender ran its LIKE over the scope's index range. That never regresses, but for a term rare in a long thread it reads the whole thread: 70-135 ms in a 127,000-message thread on a copy of the live store with ten times its history. SearchMessages now sends such a search, when its query has a literal run of three characters, to searchScope (scoped_search.go): - A conversation's newest 500 messages are read first, with the same LIKE cut at a boundary key; a term common in the thread is answered there. The rest of the answer is either the same LIKE below the boundary or the trigram index's candidates in the conversation. Both statements run in one read transaction. - The index is tried when the LIKE would still read at least max(3000, messages/50) rows: the window's hit rate applied to the matches still missing, capped by a count of the rows left. One statement then materializes at most half that many candidates of an FTS5 MATCH that ANDs the query's trigrams (trigramMatchQuery), counts them, and searches them only when there are fewer; otherwise the LIKE continues. - A sender's identities are resolved once and named in each statement. Its LIKE is replaced by the index search when the sender holds at least twice that minimum. The choice uses counts, never timings. Every path returns exactly the plain LIKE statement's rows in its order: TestSearchMessagesMatchesLikeProperty covers all six paths, TestTrigramMatchQuerySelectsFTS5LikeCandidates checks the MATCH against FTS5's own candidates for the LIKE, and TestConversationSplitMatchesLikeProperty the cut at every row. Measured on copies of the live store (old statement vs SearchMessages, interleaved, 1,780 cases, 0 mismatches): at ten times the history the median search fell from 79 to 4.2 ms in the 127,000-message thread and from 40-47 to 7-11 ms for the busiest senders; at today's size from 6.0 to 1.8 ms in the busiest thread. Ranges a little longer than the window but too short for the index pay 0.45 ms (2,900 messages) to 1.0-1.7 ms (7,800-12,700 messages at ten times the history) for the boundary seek and the count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sender path resolves the address's identities in one statement and searches their messages in another. Run it in the read transaction the conversation path already uses, so a write between the two cannot move a message to another identity of the same address and out of the answer. TestSenderSearchReadsOneSnapshot shows both: the LIKE's rows at one snapshot inside the transaction, the moved message missing outside it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
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.
Stacked on #223 (base
perf/v2-fts5-search, itself on #215); retarget tomainafter both merge. No migration.#223 kept conversation- and sender-scoped message search on its
LIKEover the scope's index range. That never regresses, but for a term rare in a long thread it reads the whole thread: 70–135 ms in a 127,000-message thread on a copy of the live store with ten times its history. This lets those searches use the trigram index when counts say it pays, without changing which rows any search returns, or their order.What changed
SearchMessagessends a nonempty query narrowed by conversation or sender, with a literal run of three characters, tosearchScope(internal/storage/sqlite/scoped_search.go). Other scoped queries (1–2 characters, empty) keep the old statement.LIKEat or above that row. If those rows holdlimitmatches, that is the answer. Otherwise it either tries the index (below) or runs the sameLIKEbelow the cut; window matches followed by the rest's firstlimit − kare exactly the old walk. A range shorter than 500 rows runs the old statement. The statements run in one read transaction, so a write between them cannot move a message across the cut (returned twice, or not at all).LIKEwould still read are estimated from the window's hit rate,ceil((limit − k) × 501 / (k + 1)), and capped by a count of the rows below the cut. The index is tried when that is at leastmax(3000, messages/50)(messages=max(rowid)).MATCHthat ANDs the query's trigrams, counts them, and, only when there are fewer than the bound, joins them tomessagesby rowid and applies the scope's filters and theLIKE. With too many candidates it returns the count alone and theLIKEcontinues. The postings are merged once either way.trigramMatchQuerybuilds thatMATCH: the query ends at its first NUL and is cut at%/_; each piece of ≥3 characters contributes every trigram as its own quoted term (adetail=noneindex refuses multi-trigram phrases). Unlike Answer v2 substring search from FTS5 trigram indexes #223'sLIKEon the FTS column, a candidate from another conversation costs one rowid lookup, not a body fetch through the index: 1.6–2.7× cheaper per candidate in the probes.identities, which has no index oncanonical_value, every time a statement runs it). The sender's rows are counted, and the index is tried when there are at least twice the minimum; otherwise theLIKEruns with the identities named. For a sender with one identity thatLIKEnow reads in time order and stops atlimit(the subquery form always read and sorted all of the sender's rows).sinceand the lower partuntil(the cut row lies inside the range, so they are implied). With both bounds SQLite seeks by the dates and only filters by the cut, reading index entries past it; plans are pinned.Measured
Old statement vs
SearchMessages, interleaved run by run (7 runs at 10x, 11 at 1x; medians), on copies of the live v2 store (freshcpofstore.sqlite3+-wal, integrity-checked; the live store was never opened) and its 10x-deep copy (722k messages;scale-deep.sh). Limits 30 and 50. Term classes per scope: absent, absent here but rare/common elsewhere, rare/uncommon/frequent/common here, the scope's top words, four phrases, 1–2 characters, and every prefix of a word as typed. Load average 25–47 during these runs. 0 mismatches in 1,780 cases.By term class in the 127,090-message thread (10x), median ms: absent 99.8 → 2.5; typed prefixes 100.4 → 2.7; absent here but rare elsewhere 110.3 → 3.9, common elsewhere 108.1 → 7.9; uncommon here 103.4 → 5.0; phrases 36.7 → 14.6; frequent here 17.0 → 13.5; common here 0.81 → 1.12; top words 0.41 → 0.58; 1–2 characters 0.25 → 0.25 (same statement). Full tables:
results/final/summary-{1x,deep10}.md.What got slower, and why.
How the constants were chosen
results/plan-bench.md: 15 terms × 11 scopes, old statement and every plan interleaved.LIKEreads in 1.3 ms at 1x; 1/50 is 14,473 rows at 10x. At 10x, 1/50 beat 1/30 and 1/100 (totals 2,318 / 2,401 / 2,671 ms against 3,940 old, at 3 rows per candidate).Earlier variants (two statements; window 2,000; senders without resolved identities) and their runs are kept under
results/final-*.Invariants (all executed as tests)
SearchMessagesreturns exactly the plainLIKEstatement's rows (whole rows,reflect.DeepEqual) in its order, through every path.TestSearchMessagesMatchesLikePropertynow randomizes the plan and adds 60 scoped searches per store; it fails unless all six paths are reached (each new path 65–700 times per run, 14 or more under-race).trigramMatchQuery'sMATCHselects exactly the rows FTS5 reads as candidates forLIKE '%q%', for every pattern FTS5 narrows, and returns an expression exactly whenlikePatternUsesTrigramsdoes (TestTrigramMatchQuerySelectsFTS5LikeCandidates: 3,000 patterns with NULs, invalid UTF-8, quotes, wildcards and expressions of hundreds of trigrams; FTS5's candidates are made observable by rewriting the content table so the re-appliedLIKEkeeps every one).LIKEwalk: the firstlimitmatches at or above the cut, then the firstlimit − kbelow, are theLIKE's rows, for every filter; the count below the cut is exact (TestConversationSplitMatchesLikeProperty).LIKE's rows (none included, as an empty slice), and otherwise the statement returns the count alone, never a message from a truncated list (the property, andTestScopeIndexSearchGivesUpAtItsBound).LIKE(the property); the sender count is the number of rows the sender'sLIKEreads (TestSenderRangeCountCountsTheSendersMessages).TestConversationSearchReadsOneSnapshotmoves a message across the cut mid-search and gets the pre-writeLIKErows, and shows the duplicate the same statements return outside a transaction;TestSenderSearchReadsOneSnapshotmoves a message to a new identity of the same address after the identities are read, and shows it missing outside one.expectedLikeRowsnever grows as the window finds more matches;scopeIndexBoundreads nothing when the estimate is below the minimum, and counts no further than it.TestSearchMessagesChoosesItsPath), and plans (TestScopedSearchPlansReadTheirIndexes): the cut and the counts read only their covering index; each part seeksmessages_conversation_time_idxfrom the cut with no sort, with and without dates; the index search reads the trigram postings first and joins by rowid; a sender's statements never readidentities.Mutation check (
mutants.py, run on f896cf1; the two transaction mutants re-run on 1b1f50e): 38 planted bugs: 21 that change results (one of them only under a concurrent write) and 17 that change only a plan, a path, the speed or a test seam. 36 are killed. The two survivors leave every test passing and the pinned plans unchanged on SQLite 3.51.2: dropping the index search's outerORDER BY(the rows already come back in the sub-query's order; it stays as the guarantee), and droppingINDEXED BYfrom the two parts (a store without statistics picks that index anyway; the hint holds if statistics ever exist). The first run left two more alive, a guard that let an over-full candidate list be searched and the missing transaction; both now have tests that kill them.Not changed
Searches across conversations (#223's paths and statements), conversation-name search, short and empty queries, schema.
MessageRepository.betweenSearchStatementsis a test hook, nil in production.Not done here, measured or noted: #223's cross-conversation trigram statement could use the same
MATCHform (1.6–2.7× cheaper per candidate); a per-conversation message count kept by triggers would remove the cost of proving a thread too short, at the price of a migration and a write per message.Evidence (harnesses, sweeps, the policy replay, plan benchmarks, raw results, mutation script and log):
~/reviews/openmessage-fts5-thread-search-2026-10-10/.Review
Independent review requested from a GPT-6.1 Sol lane (
subfleet run --task review --tier hard) against 1b1f50e; findings and responses will follow in a comment.🤖 Generated with Claude Code