From 16a4f03a4a8250f4e6c801d68ac531f4519ac084 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Wed, 19 Aug 2026 17:34:24 +0000 Subject: [PATCH 01/16] feat: stem chat search terms with the english text search config Chat full-text search used the 'simple' text search config, which folds case but does no stemming, so searching "refactor" did not match messages containing "refactoring". Switch every chat search site (chat titles, PR titles, and message bodies) to the 'english' config so inflected word forms match. The config baked into a tsvector must match the config used by the tsquery, so a migration rebuilds the two expression indexes and resets chat_messages.search_tsv to NULL; the existing dbpurge sweep repopulates the column with 'english' lexemes in bounded batches, newest first. A side effect of 'english' is stopword removal: queries consisting only of stopwords (e.g. search:"or") now tokenize to nothing and return an empty list, matching the existing empty-after-tokenization contract. --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- coderd/database/dbpurge/dbpurge_test.go | 2 +- coderd/database/dump.sql | 6 +++-- ...000574_chat_search_english_config.down.sql | 17 ++++++++++++ .../000574_chat_search_english_config.up.sql | 27 +++++++++++++++++++ coderd/database/migrations/migrate_test.go | 6 ++--- coderd/database/querier_test.go | 7 ++++- coderd/database/queries.sql.go | 14 ++++++---- coderd/database/queries/chats.sql | 14 ++++++---- coderd/exp_chats.go | 2 +- coderd/exp_chats_test.go | 23 +++++++++++++--- coderd/x/chatd/chatstate/trigger_test.go | 2 +- docs/reference/api/chats.md | 8 +++--- 14 files changed, 103 insertions(+), 29 deletions(-) create mode 100644 coderd/database/migrations/000574_chat_search_english_config.down.sql create mode 100644 coderd/database/migrations/000574_chat_search_english_config.up.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index ad0411fc667..5a4d2d17dee 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -157,7 +157,7 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "Search query. Supports ` + "`" + `title:\u003csubstring\u003e` + "`" + ` (case-insensitive, quote multi-word values), ` + "`" + `archived:bool` + "`" + `, ` + "`" + `has_unread:bool` + "`" + `, ` + "`" + `pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e` + "`" + ` as repeated or comma-separated values, ` + "`" + `source:\u003ccreated_by_me\\|shared_with_me\u003e` + "`" + `, ` + "`" + `diff_url:\u003curl\u003e` + "`" + ` (quote values containing colons), ` + "`" + `pr:\u003cnumber\u003e` + "`" + ` (exact PR number match), ` + "`" + `repo:\u003cowner/repo\u003e` + "`" + ` (case-insensitive substring match against git remote origin or URL), ` + "`" + `pr_title:\u003ctext\u003e` + "`" + ` (case-insensitive PR title substring), ` + "`" + `search:\u003ctext\u003e` + "`" + ` (full-text search across chat titles, PR titles, PR numbers, and message bodies; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words returns an empty list). Bare terms are not supported; use ` + "`" + `title:\u003cvalue\u003e` + "`" + ` or ` + "`" + `search:\u003cvalue\u003e` + "`" + `.", + "description": "Search query. Supports ` + "`" + `title:\u003csubstring\u003e` + "`" + ` (case-insensitive, quote multi-word values), ` + "`" + `archived:bool` + "`" + `, ` + "`" + `has_unread:bool` + "`" + `, ` + "`" + `pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e` + "`" + ` as repeated or comma-separated values, ` + "`" + `source:\u003ccreated_by_me\\|shared_with_me\u003e` + "`" + `, ` + "`" + `diff_url:\u003curl\u003e` + "`" + ` (quote values containing colons), ` + "`" + `pr:\u003cnumber\u003e` + "`" + ` (exact PR number match), ` + "`" + `repo:\u003cowner/repo\u003e` + "`" + ` (case-insensitive substring match against git remote origin or URL), ` + "`" + `pr_title:\u003ctext\u003e` + "`" + ` (case-insensitive PR title substring), ` + "`" + `search:\u003ctext\u003e` + "`" + ` (full-text search across chat titles, PR titles, PR numbers, and message bodies; matches English word stems, e.g. ` + "`" + `refactor` + "`" + ` matches ` + "`" + `refactoring` + "`" + `; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words, including stopword-only values, returns an empty list). Bare terms are not supported; use ` + "`" + `title:\u003cvalue\u003e` + "`" + ` or ` + "`" + `search:\u003cvalue\u003e` + "`" + `.", "name": "q", "in": "query" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index e7b75e54fa3..774cf4b6f8a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -130,7 +130,7 @@ "parameters": [ { "type": "string", - "description": "Search query. Supports `title:\u003csubstring\u003e` (case-insensitive, quote multi-word values), `archived:bool`, `has_unread:bool`, `pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e` as repeated or comma-separated values, `source:\u003ccreated_by_me\\|shared_with_me\u003e`, `diff_url:\u003curl\u003e` (quote values containing colons), `pr:\u003cnumber\u003e` (exact PR number match), `repo:\u003cowner/repo\u003e` (case-insensitive substring match against git remote origin or URL), `pr_title:\u003ctext\u003e` (case-insensitive PR title substring), `search:\u003ctext\u003e` (full-text search across chat titles, PR titles, PR numbers, and message bodies; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words returns an empty list). Bare terms are not supported; use `title:\u003cvalue\u003e` or `search:\u003cvalue\u003e`.", + "description": "Search query. Supports `title:\u003csubstring\u003e` (case-insensitive, quote multi-word values), `archived:bool`, `has_unread:bool`, `pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e` as repeated or comma-separated values, `source:\u003ccreated_by_me\\|shared_with_me\u003e`, `diff_url:\u003curl\u003e` (quote values containing colons), `pr:\u003cnumber\u003e` (exact PR number match), `repo:\u003cowner/repo\u003e` (case-insensitive substring match against git remote origin or URL), `pr_title:\u003ctext\u003e` (case-insensitive PR title substring), `search:\u003ctext\u003e` (full-text search across chat titles, PR titles, PR numbers, and message bodies; matches English word stems, e.g. `refactor` matches `refactoring`; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words, including stopword-only values, returns an empty list). Bare terms are not supported; use `title:\u003cvalue\u003e` or `search:\u003cvalue\u003e`.", "name": "q", "in": "query" }, diff --git a/coderd/database/dbpurge/dbpurge_test.go b/coderd/database/dbpurge/dbpurge_test.go index 242f9c33324..7bab2a078a3 100644 --- a/coderd/database/dbpurge/dbpurge_test.go +++ b/coderd/database/dbpurge/dbpurge_test.go @@ -3115,7 +3115,7 @@ func TestBackfillChatMessagesSearchTsv(t *testing.T) { t.Helper() var matches bool err := rawDB.QueryRowContext(ctx, - "SELECT search_tsv = to_tsvector('simple', $2::text) FROM chat_messages WHERE id = $1", id, expectedText). + "SELECT search_tsv = to_tsvector('english', $2::text) FROM chat_messages WHERE id = $1", id, expectedText). Scan(&matches) require.NoError(t, err) require.True(t, matches, "search_tsv should contain the lexemes of %q", expectedText) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index ee444df9169..c83b0c1115a 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -4780,7 +4780,9 @@ CREATE UNIQUE INDEX idx_chat_debug_steps_run_step ON chat_debug_steps USING btre CREATE INDEX idx_chat_debug_steps_stale ON chat_debug_steps USING btree (updated_at) WHERE (finished_at IS NULL); -CREATE INDEX idx_chat_diff_statuses_pr_title_fts ON chat_diff_statuses USING gin (to_tsvector('simple'::regconfig, pull_request_title)); +CREATE INDEX idx_chat_diff_statuses_pr_title_fts ON chat_diff_statuses USING gin (to_tsvector('english'::regconfig, pull_request_title)); + +COMMENT ON INDEX idx_chat_diff_statuses_pr_title_fts IS 'Used for full text search. Defined over all rows of the chat_diff_statuses table.'; CREATE INDEX idx_chat_diff_statuses_stale_at ON chat_diff_statuses USING btree (stale_at); @@ -4840,7 +4842,7 @@ CREATE INDEX idx_chats_parent_chat_id ON chats USING btree (parent_chat_id); CREATE INDEX idx_chats_root_chat_id ON chats USING btree (root_chat_id); -CREATE INDEX idx_chats_title_fts ON chats USING gin (to_tsvector('simple'::regconfig, title)); +CREATE INDEX idx_chats_title_fts ON chats USING gin (to_tsvector('english'::regconfig, title)); COMMENT ON INDEX idx_chats_title_fts IS 'Used for full text search. Defined over all rows of the chats table.'; diff --git a/coderd/database/migrations/000574_chat_search_english_config.down.sql b/coderd/database/migrations/000574_chat_search_english_config.down.sql new file mode 100644 index 00000000000..43c02b49e3c --- /dev/null +++ b/coderd/database/migrations/000574_chat_search_english_config.down.sql @@ -0,0 +1,17 @@ +-- Revert chat full-text search to the 'simple' text search config. + +DROP INDEX idx_chats_title_fts; + +CREATE INDEX idx_chats_title_fts ON chats USING GIN (to_tsvector('simple', title)); + +COMMENT ON INDEX idx_chats_title_fts IS 'Used for full text search. Defined over all rows of the chats table.'; + +DROP INDEX idx_chat_diff_statuses_pr_title_fts; + +CREATE INDEX idx_chat_diff_statuses_pr_title_fts ON chat_diff_statuses USING GIN (to_tsvector('simple', pull_request_title)); + +COMMENT ON INDEX idx_chat_diff_statuses_pr_title_fts IS 'Used for full text search. Defined over all rows of the chat_diff_statuses table.'; + +-- Reset stored vectors so the backfill sweep repopulates them with the +-- 'simple' config. +UPDATE chat_messages SET search_tsv = NULL WHERE search_tsv IS NOT NULL; diff --git a/coderd/database/migrations/000574_chat_search_english_config.up.sql b/coderd/database/migrations/000574_chat_search_english_config.up.sql new file mode 100644 index 00000000000..cb77cb820eb --- /dev/null +++ b/coderd/database/migrations/000574_chat_search_english_config.up.sql @@ -0,0 +1,27 @@ +-- Switch chat full-text search from the 'simple' to the 'english' text +-- search config so queries match inflected forms (e.g. searching +-- "refactor" matches "refactoring"). The config baked into a tsvector +-- must match the config used by the tsquery at search time, so the +-- expression indexes and the stored chat_messages.search_tsv column +-- must all be rebuilt with 'english'. + +DROP INDEX idx_chats_title_fts; + +CREATE INDEX idx_chats_title_fts ON chats USING GIN (to_tsvector('english', title)); + +COMMENT ON INDEX idx_chats_title_fts IS 'Used for full text search. Defined over all rows of the chats table.'; + +DROP INDEX idx_chat_diff_statuses_pr_title_fts; + +CREATE INDEX idx_chat_diff_statuses_pr_title_fts ON chat_diff_statuses USING GIN (to_tsvector('english', pull_request_title)); + +COMMENT ON INDEX idx_chat_diff_statuses_pr_title_fts IS 'Used for full text search. Defined over all rows of the chat_diff_statuses table.'; + +-- Reset stored vectors so the dbpurge backfill sweep repopulates them +-- with the 'english' config. NULL means "pending": eligible rows +-- re-enter idx_chat_messages_search_tsv_pending and are rebuilt in +-- bounded batches, newest first. Ineligible rows (deleted, hidden, or +-- non-user/assistant) are never queried or backfilled, so clearing +-- them is harmless and keeps the invariant that any non-NULL +-- search_tsv was produced with the current config. +UPDATE chat_messages SET search_tsv = NULL WHERE search_tsv IS NOT NULL; diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index df0c7d14ea9..c24ffac1656 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -2516,7 +2516,7 @@ func TestMigration000543ChatSearchSchemaBehavior(t *testing.T) { // queue. _, err = sqlDB.ExecContext(ctx, ` UPDATE chat_messages - SET search_tsv = COALESCE(to_tsvector('simple', chat_message_search_text(content)), ''::tsvector) + SET search_tsv = COALESCE(to_tsvector('english', chat_message_search_text(content)), ''::tsvector) WHERE id = ANY($1)`, pq.Array([]int64{eligibleText.ID, eligibleNoText.ID})) require.NoError(t, err) require.Empty(t, pendingIDs(ctx, 10), "swept rows must leave the queue, including no-text rows") @@ -2532,13 +2532,13 @@ func TestMigration000543ChatSearchSchemaBehavior(t *testing.T) { // ineligible ones) and assert the search-index predicate filters them. _, err = sqlDB.ExecContext(ctx, ` UPDATE chat_messages - SET search_tsv = COALESCE(to_tsvector('simple', chat_message_search_text(content)), ''::tsvector) + SET search_tsv = COALESCE(to_tsvector('english', chat_message_search_text(content)), ''::tsvector) WHERE chat_id = $1`, chat.ID) require.NoError(t, err) rows, err := sqlDB.QueryContext(ctx, ` SELECT id FROM chat_messages - WHERE search_tsv @@ websearch_to_tsquery('simple', $1) + WHERE search_tsv @@ websearch_to_tsquery('english', $1) AND search_tsv IS NOT NULL AND `+eligibilityPredicate+` ORDER BY id`, "deploy") diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 2c4df715781..53ecff7478c 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -18089,7 +18089,7 @@ func TestGetChatsSearch(t *testing.T) { // Prove role/visibility predicates exclude rows even when search_tsv // is set. _, err = sqlDB.ExecContext(ctx, - `UPDATE chat_messages SET search_tsv = to_tsvector('simple', 'forbidden secret token') WHERE id = ANY($1)`, + `UPDATE chat_messages SET search_tsv = to_tsvector('english', 'forbidden secret token') WHERE id = ANY($1)`, pq.Array([]int64{toolMsg.ID, modelOnlyMsg.ID})) require.NoError(t, err) @@ -18111,8 +18111,13 @@ func TestGetChatsSearch(t *testing.T) { {"Title/Match", database.GetChatsParams{Search: "pipeline alpha"}, []uuid.UUID{titleChat.ID}}, {"Title/CaseInsensitiveMultiWord", database.GetChatsParams{Search: "ALPHA DEPLOY"}, []uuid.UUID{titleChat.ID}}, {"Title/AndSemantics", database.GetChatsParams{Search: "deploy nonexistent"}, nil}, + // The 'english' config stems both sides, so inflected query forms + // match the stored words. + {"Title/StemmedMatch", database.GetChatsParams{Search: "deploying pipelines"}, []uuid.UUID{titleChat.ID, archivedChat.ID}}, {"PRTitle/Match", database.GetChatsParams{Search: "authentication"}, []uuid.UUID{prTitleChat.ID, mergedChat.ID}}, + {"PRTitle/StemmedMatch", database.GetChatsParams{Search: "authenticating"}, []uuid.UUID{prTitleChat.ID, mergedChat.ID}}, {"Message/Match", database.GetChatsParams{Search: "kubernetes restart"}, []uuid.UUID{msgChat.ID}}, + {"Message/StemmedMatch", database.GetChatsParams{Search: "restarting clusters"}, []uuid.UUID{msgChat.ID}}, {"Message/AssistantRoleMatch", database.GetChatsParams{Search: "grafana tuning"}, []uuid.UUID{assistantMsgChat.ID}}, {"Message/UserVisibilityMatch", database.GetChatsParams{Search: "vault rotation"}, []uuid.UUID{userVisMsgChat.ID}}, {"Message/AssistantUserVisibilityMatch", database.GetChatsParams{Search: "redis eviction"}, []uuid.UUID{assistantUserVisMsgChat.ID}}, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 39c721ce360..dbf6406c96e 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6969,7 +6969,7 @@ WITH batch AS ( ) UPDATE chat_messages cm SET search_tsv = COALESCE( - to_tsvector('simple', chat_message_search_text(cm.content)), + to_tsvector('english', chat_message_search_text(cm.content)), ''::tsvector) FROM batch WHERE cm.id = batch.id ` @@ -9123,17 +9123,21 @@ WHERE ELSE true END -- websearch_to_tsquery accepts quoted phrases, OR, and -negation; - -- the 'simple' config folds case and skips stemming. + -- the 'english' config folds case and stems words, so inflected + -- forms match (e.g. "refactor" matches "refactoring"). The config + -- must match the one baked into the tsvectors and expression + -- indexes (idx_chats_title_fts, idx_chat_diff_statuses_pr_title_fts, + -- and the backfilled chat_messages.search_tsv). AND CASE WHEN $16::text != '' THEN ( -- Served by idx_chats_title_fts. - to_tsvector('simple', chats_expanded.title) @@ websearch_to_tsquery('simple', $16) + to_tsvector('english', chats_expanded.title) @@ websearch_to_tsquery('english', $16) -- Served by idx_chat_diff_statuses_pr_title_fts. OR EXISTS ( SELECT 1 FROM chat_diff_statuses cds WHERE cds.chat_id = chats_expanded.id - AND to_tsvector('simple', cds.pull_request_title) @@ websearch_to_tsquery('simple', $16) + AND to_tsvector('english', cds.pull_request_title) @@ websearch_to_tsquery('english', $16) ) -- The WHERE clause must repeat the predicate of the partial index -- idx_chat_messages_search_tsv so the planner can use it. Additional @@ -9146,7 +9150,7 @@ WHERE AND cm.deleted = false AND cm.visibility IN ('user', 'both') AND cm.role IN ('user', 'assistant') - AND cm.search_tsv @@ websearch_to_tsquery('simple', $16) + AND cm.search_tsv @@ websearch_to_tsquery('english', $16) ) -- Skip an explicit pr_number lookup unless the search is a valid bigint. OR CASE diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index a320e0163df..9ee21b163e4 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -331,7 +331,7 @@ WITH batch AS ( UPDATE chat_messages cm -- NULL means "pending", empty tsvector means "backfilled, no text". SET search_tsv = COALESCE( - to_tsvector('simple', chat_message_search_text(cm.content)), + to_tsvector('english', chat_message_search_text(cm.content)), ''::tsvector) FROM batch WHERE cm.id = batch.id; @@ -679,17 +679,21 @@ WHERE ELSE true END -- websearch_to_tsquery accepts quoted phrases, OR, and -negation; - -- the 'simple' config folds case and skips stemming. + -- the 'english' config folds case and stems words, so inflected + -- forms match (e.g. "refactor" matches "refactoring"). The config + -- must match the one baked into the tsvectors and expression + -- indexes (idx_chats_title_fts, idx_chat_diff_statuses_pr_title_fts, + -- and the backfilled chat_messages.search_tsv). AND CASE WHEN @search::text != '' THEN ( -- Served by idx_chats_title_fts. - to_tsvector('simple', chats_expanded.title) @@ websearch_to_tsquery('simple', @search) + to_tsvector('english', chats_expanded.title) @@ websearch_to_tsquery('english', @search) -- Served by idx_chat_diff_statuses_pr_title_fts. OR EXISTS ( SELECT 1 FROM chat_diff_statuses cds WHERE cds.chat_id = chats_expanded.id - AND to_tsvector('simple', cds.pull_request_title) @@ websearch_to_tsquery('simple', @search) + AND to_tsvector('english', cds.pull_request_title) @@ websearch_to_tsquery('english', @search) ) -- The WHERE clause must repeat the predicate of the partial index -- idx_chat_messages_search_tsv so the planner can use it. Additional @@ -702,7 +706,7 @@ WHERE AND cm.deleted = false AND cm.visibility IN ('user', 'both') AND cm.role IN ('user', 'assistant') - AND cm.search_tsv @@ websearch_to_tsquery('simple', @search) + AND cm.search_tsv @@ websearch_to_tsquery('english', @search) ) -- Skip an explicit pr_number lookup unless the search is a valid bigint. OR CASE diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 8b3f69ee5c0..7a61aae7f2e 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -363,7 +363,7 @@ func (api *API) chatsByWorkspace(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Tags Chats // @Produce json -// @Param q query string false "Search query. Supports `title:` (case-insensitive, quote multi-word values), `archived:bool`, `has_unread:bool`, `pr_status:` as repeated or comma-separated values, `source:`, `diff_url:` (quote values containing colons), `pr:` (exact PR number match), `repo:` (case-insensitive substring match against git remote origin or URL), `pr_title:` (case-insensitive PR title substring), `search:` (full-text search across chat titles, PR titles, PR numbers, and message bodies; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words returns an empty list). Bare terms are not supported; use `title:` or `search:`." +// @Param q query string false "Search query. Supports `title:` (case-insensitive, quote multi-word values), `archived:bool`, `has_unread:bool`, `pr_status:` as repeated or comma-separated values, `source:`, `diff_url:` (quote values containing colons), `pr:` (exact PR number match), `repo:` (case-insensitive substring match against git remote origin or URL), `pr_title:` (case-insensitive PR title substring), `search:` (full-text search across chat titles, PR titles, PR numbers, and message bodies; matches English word stems, e.g. `refactor` matches `refactoring`; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words, including stopword-only values, returns an empty list). Bare terms are not supported; use `title:` or `search:`." // @Param label query string false "Filter by label as key:value. Repeat for multiple (AND logic)." // @Success 200 {array} codersdk.Chat // @Router /api/experimental/chats [get] diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index da0edd73215..de24a243778 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -2320,24 +2320,39 @@ func TestListChats_Search(t *testing.T) { require.Contains(t, ids, titleMatch.ID) require.Contains(t, ids, bodyMatch.ID) require.NotContains(t, ids, noMatch.ID) + + // The 'english' config stems both sides, so an inflected query + // form matches the stored words. + chats, err = client.ListChats(ctx, &codersdk.ListChatsOptions{ + Query: `search:"restarting"`, + }) + require.NoError(t, err) + require.Contains(t, chatIDs(chats), bodyMatch.ID) }) t.Run("NoSearchableWordsReturnsEmpty", func(t *testing.T) { t.Parallel() ctx, client, db, firstUser, modelConfig := setup(t) - // "or" is a real lexeme (an operator only between operands), so - // search:"or" matches the control chat; search:"!!!" has no lexemes and - // matches nothing. + // The 'english' config drops stopwords, so search:"or" tokenizes + // to no lexemes and matches nothing, just like search:"!!!". The + // control query proves the empty results come from tokenization, + // not missing data. control := createChat(t, db, firstUser, modelConfig.ID, "fix this or that") backfillSearchTsv(ctx, t, db) chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{ - Query: `search:"or"`, + Query: `search:"fix"`, }) require.NoError(t, err) require.Contains(t, chatIDs(chats), control.ID) + chats, err = client.ListChats(ctx, &codersdk.ListChatsOptions{ + Query: `search:"or"`, + }) + require.NoError(t, err) + require.Empty(t, chats) + chats, err = client.ListChats(ctx, &codersdk.ListChatsOptions{ Query: `search:"!!!"`, }) diff --git a/coderd/x/chatd/chatstate/trigger_test.go b/coderd/x/chatd/chatstate/trigger_test.go index 41a95cf58f2..fc86811b88c 100644 --- a/coderd/x/chatd/chatstate/trigger_test.go +++ b/coderd/x/chatd/chatstate/trigger_test.go @@ -307,7 +307,7 @@ func TestSearchTsvBackfillDoesNotTouchChatState(t *testing.T) { // Backfill-shaped UPDATE: only search_tsv changes. _, err = tf.sqlDB.ExecContext(ctx, ` UPDATE chat_messages - SET search_tsv = COALESCE(to_tsvector('simple', chat_message_search_text(content)), ''::tsvector) + SET search_tsv = COALESCE(to_tsvector('english', chat_message_search_text(content)), ''::tsvector) WHERE id = $1 `, target.ID) require.NoError(t, err) diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index db24aa4c3e0..4b14722ce80 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -27,10 +27,10 @@ Experimental: this endpoint is subject to change. ### Parameters -| Name | In | Type | Required | Description | -|---------|-------|--------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `q` | query | string | false | Search query. Supports `title:` (case-insensitive, quote multi-word values), `archived:bool`, `has_unread:bool`, `pr_status:` as repeated or comma-separated values, `source:`, `diff_url:` (quote values containing colons), `pr:` (exact PR number match), `repo:` (case-insensitive substring match against git remote origin or URL), `pr_title:` (case-insensitive PR title substring), `search:` (full-text search across chat titles, PR titles, PR numbers, and message bodies; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words returns an empty list). Bare terms are not supported; use `title:` or `search:`. | -| `label` | query | string | false | Filter by label as key:value. Repeat for multiple (AND logic). | +| Name | In | Type | Required | Description | +|---------|-------|--------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `q` | query | string | false | Search query. Supports `title:` (case-insensitive, quote multi-word values), `archived:bool`, `has_unread:bool`, `pr_status:` as repeated or comma-separated values, `source:`, `diff_url:` (quote values containing colons), `pr:` (exact PR number match), `repo:` (case-insensitive substring match against git remote origin or URL), `pr_title:` (case-insensitive PR title substring), `search:` (full-text search across chat titles, PR titles, PR numbers, and message bodies; matches English word stems, e.g. `refactor` matches `refactoring`; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words, including stopword-only values, returns an empty list). Bare terms are not supported; use `title:` or `search:`. | +| `label` | query | string | false | Filter by label as key:value. Repeat for multiple (AND logic). | ### Example responses From 471548ec04de92e65d3862b90daed8b1fb56ad0f Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Thu, 20 Aug 2026 19:12:15 +0000 Subject: [PATCH 02/16] fix: track chat search vector config instead of resetting in migration Address review feedback on the english text search config switch: The migration no longer rewrites chat_messages. A full-table UPDATE of search_tsv would rewrite every indexed row inside the migration transaction, churn both partial indexes, and hold row locks and WAL until the batch commits. Instead a new search_tsv_config column records which config produced each stored vector. The pending-queue index and backfill sweep treat any row whose config is not 'english' as pending, so the existing bounded dbpurge sweep re-vectorizes the backlog incrementally, newest first. Old binaries in a rolling upgrade cannot set the column, so any 'simple' vectors they write stay pending and are rewritten by an upgraded replica's sweep instead of being stranded with mismatched lexemes. Both chatd trigger functions now exclude search_tsv_config alongside search_tsv so backfill batches do not advance message revisions or chat history_version. --- coderd/database/dbpurge/dbpurge_test.go | 48 +++++++- coderd/database/dump.sql | 16 ++- ...000574_chat_search_english_config.down.sql | 84 +++++++++++++- .../000574_chat_search_english_config.up.sql | 109 ++++++++++++++++-- coderd/database/migrations/migrate_test.go | 36 +++++- coderd/database/models.go | 2 + coderd/database/querier.go | 12 +- coderd/database/queries.sql.go | 46 +++++--- coderd/database/queries/chats.sql | 19 ++- coderd/x/chatd/ARCHITECTURE.md | 2 + coderd/x/chatd/chatstate/trigger_test.go | 5 +- 11 files changed, 325 insertions(+), 54 deletions(-) diff --git a/coderd/database/dbpurge/dbpurge_test.go b/coderd/database/dbpurge/dbpurge_test.go index 7bab2a078a3..2f956f66045 100644 --- a/coderd/database/dbpurge/dbpurge_test.go +++ b/coderd/database/dbpurge/dbpurge_test.go @@ -3090,7 +3090,7 @@ func TestBackfillChatMessagesSearchTsv(t *testing.T) { var count int err := rawDB.QueryRowContext(ctx, ` SELECT COUNT(*) FROM chat_messages - WHERE search_tsv IS NULL + WHERE (search_tsv IS NULL OR search_tsv_config IS DISTINCT FROM 'english') AND deleted = false AND visibility IN ('user', 'both') AND role IN ('user', 'assistant')`).Scan(&count) @@ -3110,15 +3110,19 @@ func TestBackfillChatMessagesSearchTsv(t *testing.T) { isNull, _ := searchTsv(ctx, t, rawDB, id) require.False(t, isNull, msg) } - // Asserts the row's tsvector matches expectedText, not just non-NULL. + // Asserts the row's tsvector matches expectedText and that the sweep + // stamped the config that produced it. requireTsvFor := func(ctx context.Context, t *testing.T, rawDB *sql.DB, id int64, expectedText string) { t.Helper() var matches bool + var config sql.NullString err := rawDB.QueryRowContext(ctx, - "SELECT search_tsv = to_tsvector('english', $2::text) FROM chat_messages WHERE id = $1", id, expectedText). - Scan(&matches) + "SELECT search_tsv = to_tsvector('english', $2::text), search_tsv_config FROM chat_messages WHERE id = $1", id, expectedText). + Scan(&matches, &config) require.NoError(t, err) require.True(t, matches, "search_tsv should contain the lexemes of %q", expectedText) + require.Equal(t, sql.NullString{String: "english", Valid: true}, config, + "backfilled rows must record the config that produced the vector") } requireNotBackfilled := func(ctx context.Context, t *testing.T, rawDB *sql.DB, id int64, msg string) { t.Helper() @@ -3281,6 +3285,42 @@ func TestBackfillChatMessagesSearchTsv(t *testing.T) { requireBackfilled(ctx, t, rawDB, fresh.ID, "message inserted after drain should be backfilled on the next tick") }) + //nolint:paralleltest // It uses LockIDDBPurge. + t.Run("ReindexesStaleConfigVectors", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + clk := quartz.NewMock(t) + clk.Set(now).MustWait(ctx) + db, _, rawDB := dbtestutil.NewDBWithSQLDB(t, dbtestutil.WithDumpOnFailure()) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + deps := setupDeps(t, db) + + msg := createMessage(t, db, deps, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, textContent("refactoring the deployment")) + + tick := awaitDoTicks(ctx, t, clk, 2) + closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), dbpurge.WithClock(clk)) + defer closer.Close() + + tick() + requireTsvFor(ctx, t, rawDB, msg.ID, "refactoring the deployment") + + // Simulate a binary that predates search_tsv_config (e.g. an old + // replica winning the dbpurge lock mid rolling upgrade): it writes + // a 'simple' vector and cannot stamp the config column. The row + // must re-enter the pending queue and be rewritten, not stay + // wrongly indexed forever. + _, err := rawDB.ExecContext(ctx, ` + UPDATE chat_messages + SET search_tsv = to_tsvector('simple', chat_message_search_text(content)), + search_tsv_config = NULL + WHERE id = $1`, msg.ID) + require.NoError(t, err) + require.Equal(t, 1, countPending(ctx, t, rawDB), "stale-config vector should be pending again") + + tick() + requireTsvFor(ctx, t, rawDB, msg.ID, "refactoring the deployment") + require.Zero(t, countPending(ctx, t, rawDB)) + }) + //nolint:paralleltest // It uses LockIDDBPurge. t.Run("SteadyStateNoop", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index c83b0c1115a..246b07e5c22 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1405,6 +1405,7 @@ BEGIN cmp := NEW; cmp.search_tsv := OLD.search_tsv; + cmp.search_tsv_config := OLD.search_tsv_config; IF OLD IS NOT DISTINCT FROM cmp THEN RETURN NEW; END IF; @@ -1422,7 +1423,7 @@ BEGIN END; $$; -COMMENT ON FUNCTION set_chat_message_revision_before() IS 'Component of chatd. Updates chat_snapshot_version when any fields of chat_messages change. Excludes changes to search_tsv as it is not relevant to chatd''s processing loop.'; +COMMENT ON FUNCTION set_chat_message_revision_before() IS 'Component of chatd. Updates chat_snapshot_version when any fields of chat_messages change. Excludes changes to search_tsv and search_tsv_config as they are not relevant to chatd''s processing loop.'; CREATE FUNCTION sync_chat_retry_state() RETURNS trigger LANGUAGE plpgsql @@ -1474,7 +1475,7 @@ BEGIN SELECT DISTINCT n.chat_id FROM chat_message_history_new_rows n JOIN chat_message_history_old_rows o ON o.id = n.id - WHERE (to_jsonb(o) - 'search_tsv') IS DISTINCT FROM (to_jsonb(n) - 'search_tsv') + WHERE (to_jsonb(o) - 'search_tsv' - 'search_tsv_config') IS DISTINCT FROM (to_jsonb(n) - 'search_tsv' - 'search_tsv_config') ) AS affected WHERE c.id = affected.chat_id AND ( @@ -1485,7 +1486,7 @@ BEGIN END; $$; -COMMENT ON FUNCTION update_chat_history_after_message_update() IS 'Component of chatd. Updates history_version and generation_attempt on chats when chat_messages is updated. Excludes changes to search_tsv.'; +COMMENT ON FUNCTION update_chat_history_after_message_update() IS 'Component of chatd. Updates history_version and generation_attempt on chats when chat_messages is updated. Excludes changes to search_tsv and search_tsv_config.'; CREATE TABLE ai_gateway_keys ( id uuid NOT NULL, @@ -1976,13 +1977,16 @@ CREATE TABLE chat_messages ( provider_response_id text, revision bigint NOT NULL, reasoning_effort chat_reasoning_effort, - search_tsv tsvector + search_tsv tsvector, + search_tsv_config text ); COMMENT ON COLUMN chat_messages.reasoning_effort IS 'Stores the selected effort for the turn triggered by this message.'; COMMENT ON COLUMN chat_messages.search_tsv IS 'Used for full text search. NULL initially, populated async via background job.'; +COMMENT ON COLUMN chat_messages.search_tsv_config IS 'Text search config that produced search_tsv. NULL means the vector is stale (produced by an unknown config) and the row is pending re-vectorization. Binaries that predate this column cannot set it, so vectors written by an old replica during a rolling upgrade stay pending and are rewritten by an upgraded replica''s sweep.'; + CREATE SEQUENCE chat_messages_id_seq START WITH 1 INCREMENT BY 1 @@ -4814,7 +4818,9 @@ CREATE INDEX idx_chat_messages_search_tsv ON chat_messages USING gin (search_tsv COMMENT ON INDEX idx_chat_messages_search_tsv IS 'Partial index over chat_messages used for populating search_tsv in the background. Only defined over ''searchable'' rows of chat_messages where search_tsv is NULL.'; -CREATE INDEX idx_chat_messages_search_tsv_pending ON chat_messages USING btree (id DESC) WHERE ((search_tsv IS NULL) AND (deleted = false) AND (visibility = ANY (ARRAY['user'::chat_message_visibility, 'both'::chat_message_visibility])) AND (role = ANY (ARRAY['user'::chat_message_role, 'assistant'::chat_message_role]))); +CREATE INDEX idx_chat_messages_search_tsv_pending ON chat_messages USING btree (id DESC) WHERE (((search_tsv IS NULL) OR (search_tsv_config IS DISTINCT FROM 'english'::text)) AND (deleted = false) AND (visibility = ANY (ARRAY['user'::chat_message_visibility, 'both'::chat_message_visibility])) AND (role = ANY (ARRAY['user'::chat_message_role, 'assistant'::chat_message_role]))); + +COMMENT ON INDEX idx_chat_messages_search_tsv_pending IS 'Partial index over chat_messages used for populating search_tsv in the background. Only defined over ''searchable'' rows of chat_messages whose search_tsv is NULL or was produced with a stale text search config.'; CREATE INDEX idx_chat_messages_user_prompts ON chat_messages USING btree (chat_id, id DESC) WHERE ((deleted = false) AND (role = 'user'::chat_message_role) AND (visibility = ANY (ARRAY['user'::chat_message_visibility, 'both'::chat_message_visibility]))); diff --git a/coderd/database/migrations/000574_chat_search_english_config.down.sql b/coderd/database/migrations/000574_chat_search_english_config.down.sql index 43c02b49e3c..fcdb257dce8 100644 --- a/coderd/database/migrations/000574_chat_search_english_config.down.sql +++ b/coderd/database/migrations/000574_chat_search_english_config.down.sql @@ -1,4 +1,10 @@ -- Revert chat full-text search to the 'simple' text search config. +-- Stored 'english' vectors in chat_messages.search_tsv are left in +-- place: rewriting them would be the same full-table UPDATE the up +-- migration avoids, and there is no config column left to drive an +-- incremental re-vectorization. Searches against those rows degrade +-- to exact-lexeme matches until the rows are deleted or re-indexed by +-- other means. DROP INDEX idx_chats_title_fts; @@ -12,6 +18,78 @@ CREATE INDEX idx_chat_diff_statuses_pr_title_fts ON chat_diff_statuses USING GIN COMMENT ON INDEX idx_chat_diff_statuses_pr_title_fts IS 'Used for full text search. Defined over all rows of the chat_diff_statuses table.'; --- Reset stored vectors so the backfill sweep repopulates them with the --- 'simple' config. -UPDATE chat_messages SET search_tsv = NULL WHERE search_tsv IS NOT NULL; +-- The pending index predicate references search_tsv_config, so it must +-- be dropped before the column. +DROP INDEX idx_chat_messages_search_tsv_pending; + +ALTER TABLE chat_messages DROP COLUMN search_tsv_config; + +CREATE INDEX idx_chat_messages_search_tsv_pending ON chat_messages USING btree (id DESC) +WHERE ((search_tsv IS NULL) AND (deleted = false) AND (visibility = ANY (ARRAY['user'::chat_message_visibility, 'both'::chat_message_visibility])) AND (role = ANY (ARRAY['user'::chat_message_role, 'assistant'::chat_message_role]))); + +COMMENT ON INDEX idx_chat_messages_search_tsv_pending IS 'Partial index over chat_messages used for populating search_tsv in the background. Only defined over ''searchable'' rows of chat_messages where search_tsv is NULL.'; + +-- Restore the original trigger function bodies from 000545. + +CREATE OR REPLACE FUNCTION set_chat_message_revision_before() +RETURNS trigger AS $$ +DECLARE + chat_snapshot_version bigint; + cmp chat_messages; +BEGIN + IF TG_OP = 'INSERT' AND NEW.revision IS NOT NULL THEN + RAISE EXCEPTION 'chat_messages.revision must be assigned by trigger'; + END IF; + + IF TG_OP = 'UPDATE' THEN + IF OLD.chat_id IS DISTINCT FROM NEW.chat_id THEN + RAISE EXCEPTION 'chat_messages.chat_id is immutable'; + END IF; + + IF OLD.revision IS DISTINCT FROM NEW.revision THEN + RAISE EXCEPTION 'chat_messages.revision must be assigned by trigger'; + END IF; + + cmp := NEW; + cmp.search_tsv := OLD.search_tsv; + IF OLD IS NOT DISTINCT FROM cmp THEN + RETURN NEW; + END IF; + END IF; + + SELECT snapshot_version INTO chat_snapshot_version + FROM chats WHERE id = NEW.chat_id; + + IF chat_snapshot_version IS NULL THEN + RAISE EXCEPTION 'chat % does not exist', NEW.chat_id; + END IF; + + NEW.revision = chat_snapshot_version; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION set_chat_message_revision_before IS 'Component of chatd. Updates chat_snapshot_version when any fields of chat_messages change. Excludes changes to search_tsv as it is not relevant to chatd''s processing loop.'; + +CREATE OR REPLACE FUNCTION update_chat_history_after_message_update() +RETURNS trigger AS $$ +BEGIN + UPDATE chats c + SET history_version = c.snapshot_version, + generation_attempt = 0 + FROM ( + SELECT DISTINCT n.chat_id + FROM chat_message_history_new_rows n + JOIN chat_message_history_old_rows o ON o.id = n.id + WHERE (to_jsonb(o) - 'search_tsv') IS DISTINCT FROM (to_jsonb(n) - 'search_tsv') + ) AS affected + WHERE c.id = affected.chat_id + AND ( + c.history_version IS DISTINCT FROM c.snapshot_version + OR c.generation_attempt <> 0 + ); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION update_chat_history_after_message_update IS 'Component of chatd. Updates history_version and generation_attempt on chats when chat_messages is updated. Excludes changes to search_tsv.'; diff --git a/coderd/database/migrations/000574_chat_search_english_config.up.sql b/coderd/database/migrations/000574_chat_search_english_config.up.sql index cb77cb820eb..cc3c30ce50f 100644 --- a/coderd/database/migrations/000574_chat_search_english_config.up.sql +++ b/coderd/database/migrations/000574_chat_search_english_config.up.sql @@ -1,9 +1,18 @@ -- Switch chat full-text search from the 'simple' to the 'english' text -- search config so queries match inflected forms (e.g. searching -- "refactor" matches "refactoring"). The config baked into a tsvector --- must match the config used by the tsquery at search time, so the --- expression indexes and the stored chat_messages.search_tsv column --- must all be rebuilt with 'english'. +-- must match the config used by the tsquery at search time. +-- +-- The title expression indexes are rebuilt here; chats and +-- chat_diff_statuses hold one row per chat, so the rebuild is cheap. +-- The stored chat_messages.search_tsv vectors are NOT rewritten here: +-- chat_messages can be large, and a full-table UPDATE inside the +-- migration transaction would rewrite every row, churn both partial +-- indexes, and hold the locks and WAL until the whole migration batch +-- commits. Instead the new search_tsv_config column records which +-- config produced each stored vector, the pending-queue index treats +-- any row whose config is not 'english' as pending, and the bounded +-- dbpurge sweep rewrites stale rows incrementally, newest first. DROP INDEX idx_chats_title_fts; @@ -17,11 +26,89 @@ CREATE INDEX idx_chat_diff_statuses_pr_title_fts ON chat_diff_statuses USING GIN COMMENT ON INDEX idx_chat_diff_statuses_pr_title_fts IS 'Used for full text search. Defined over all rows of the chat_diff_statuses table.'; --- Reset stored vectors so the dbpurge backfill sweep repopulates them --- with the 'english' config. NULL means "pending": eligible rows --- re-enter idx_chat_messages_search_tsv_pending and are rebuilt in --- bounded batches, newest first. Ineligible rows (deleted, hidden, or --- non-user/assistant) are never queried or backfilled, so clearing --- them is harmless and keeps the invariant that any non-NULL --- search_tsv was produced with the current config. -UPDATE chat_messages SET search_tsv = NULL WHERE search_tsv IS NOT NULL; +ALTER TABLE chat_messages ADD COLUMN search_tsv_config text; + +COMMENT ON COLUMN chat_messages.search_tsv_config IS 'Text search config that produced search_tsv. NULL means the vector is stale (produced by an unknown config) and the row is pending re-vectorization. Binaries that predate this column cannot set it, so vectors written by an old replica during a rolling upgrade stay pending and are rewritten by an upgraded replica''s sweep.'; + +-- A row is pending when it has never been vectorized (search_tsv IS +-- NULL) or when its stored vector was produced with a different config +-- (search_tsv_config IS DISTINCT FROM 'english'). All pre-existing +-- rows have search_tsv_config NULL, so the entire eligible backlog +-- re-enters this queue with no data movement; the index shrinks as the +-- sweep drains it. +DROP INDEX idx_chat_messages_search_tsv_pending; + +CREATE INDEX idx_chat_messages_search_tsv_pending ON chat_messages USING btree (id DESC) +WHERE (((search_tsv IS NULL) OR (search_tsv_config IS DISTINCT FROM 'english')) AND (deleted = false) AND (visibility = ANY (ARRAY['user'::chat_message_visibility, 'both'::chat_message_visibility])) AND (role = ANY (ARRAY['user'::chat_message_role, 'assistant'::chat_message_role]))); + +COMMENT ON INDEX idx_chat_messages_search_tsv_pending IS 'Partial index over chat_messages used for populating search_tsv in the background. Only defined over ''searchable'' rows of chat_messages whose search_tsv is NULL or was produced with a stale text search config.'; + +-- The sweep now also writes search_tsv_config, so both chatd trigger +-- functions must exclude it (alongside search_tsv) when deciding +-- whether a message change is meaningful. Without this every backfill +-- batch would advance message revisions and chat history_version, +-- waking chatd's processing loop for the whole backlog. + +CREATE OR REPLACE FUNCTION set_chat_message_revision_before() +RETURNS trigger AS $$ +DECLARE + chat_snapshot_version bigint; + cmp chat_messages; +BEGIN + IF TG_OP = 'INSERT' AND NEW.revision IS NOT NULL THEN + RAISE EXCEPTION 'chat_messages.revision must be assigned by trigger'; + END IF; + + IF TG_OP = 'UPDATE' THEN + IF OLD.chat_id IS DISTINCT FROM NEW.chat_id THEN + RAISE EXCEPTION 'chat_messages.chat_id is immutable'; + END IF; + + IF OLD.revision IS DISTINCT FROM NEW.revision THEN + RAISE EXCEPTION 'chat_messages.revision must be assigned by trigger'; + END IF; + + cmp := NEW; + cmp.search_tsv := OLD.search_tsv; + cmp.search_tsv_config := OLD.search_tsv_config; + IF OLD IS NOT DISTINCT FROM cmp THEN + RETURN NEW; + END IF; + END IF; + + SELECT snapshot_version INTO chat_snapshot_version + FROM chats WHERE id = NEW.chat_id; + + IF chat_snapshot_version IS NULL THEN + RAISE EXCEPTION 'chat % does not exist', NEW.chat_id; + END IF; + + NEW.revision = chat_snapshot_version; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION set_chat_message_revision_before IS 'Component of chatd. Updates chat_snapshot_version when any fields of chat_messages change. Excludes changes to search_tsv and search_tsv_config as they are not relevant to chatd''s processing loop.'; + +CREATE OR REPLACE FUNCTION update_chat_history_after_message_update() +RETURNS trigger AS $$ +BEGIN + UPDATE chats c + SET history_version = c.snapshot_version, + generation_attempt = 0 + FROM ( + SELECT DISTINCT n.chat_id + FROM chat_message_history_new_rows n + JOIN chat_message_history_old_rows o ON o.id = n.id + WHERE (to_jsonb(o) - 'search_tsv' - 'search_tsv_config') IS DISTINCT FROM (to_jsonb(n) - 'search_tsv' - 'search_tsv_config') + ) AS affected + WHERE c.id = affected.chat_id + AND ( + c.history_version IS DISTINCT FROM c.snapshot_version + OR c.generation_attempt <> 0 + ); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION update_chat_history_after_message_update IS 'Component of chatd. Updates history_version and generation_attempt on chats when chat_messages is updated. Excludes changes to search_tsv and search_tsv_config.'; diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index c24ffac1656..9eb399b6cf9 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -2391,6 +2391,11 @@ const eligibilityPredicate = `deleted = false AND visibility IN ('user', 'both') AND role IN ('user', 'assistant')` +// Pending-queue membership: never vectorized, or vectorized with a +// stale text search config. Must match the predicate of +// idx_chat_messages_search_tsv_pending. +const pendingPredicate = `(search_tsv IS NULL OR search_tsv_config IS DISTINCT FROM 'english')` + func TestMigration000543ChatSearchSchemaIndexes(t *testing.T) { t.Parallel() if testing.Short() { @@ -2474,7 +2479,7 @@ func TestMigration000543ChatSearchSchemaBehavior(t *testing.T) { pendingIDs := func(ctx context.Context, limit int) []int64 { rows, err := sqlDB.QueryContext(ctx, ` SELECT id FROM chat_messages - WHERE search_tsv IS NULL AND `+eligibilityPredicate+` + WHERE `+pendingPredicate+` AND `+eligibilityPredicate+` ORDER BY id DESC LIMIT $1`, limit) require.NoError(t, err) @@ -2512,15 +2517,35 @@ func TestMigration000543ChatSearchSchemaBehavior(t *testing.T) { require.Equal(t, []int64{eligibleNoText.ID, eligibleText.ID}, pendingIDs(ctx, 10)) // Sweep-style UPDATE. The '' sentinel (not NULL) marks no-text rows as - // swept; NULL means pending, so COALESCE is what drains them from the - // queue. + // swept; search_tsv_config records the config and is what drains rows + // from the queue. _, err = sqlDB.ExecContext(ctx, ` UPDATE chat_messages - SET search_tsv = COALESCE(to_tsvector('english', chat_message_search_text(content)), ''::tsvector) + SET search_tsv = COALESCE(to_tsvector('english', chat_message_search_text(content)), ''::tsvector), + search_tsv_config = 'english' WHERE id = ANY($1)`, pq.Array([]int64{eligibleText.ID, eligibleNoText.ID})) require.NoError(t, err) require.Empty(t, pendingIDs(ctx, 10), "swept rows must leave the queue, including no-text rows") + // A non-NULL vector with a stale config stays pending: this is how + // rows written by an old binary during a rolling upgrade (which + // cannot set search_tsv_config) re-enter the queue and self-heal. + _, err = sqlDB.ExecContext(ctx, ` + UPDATE chat_messages + SET search_tsv = to_tsvector('simple', chat_message_search_text(content)), + search_tsv_config = NULL + WHERE id = $1`, eligibleText.ID) + require.NoError(t, err) + require.Equal(t, []int64{eligibleText.ID}, pendingIDs(ctx, 10), + "stale-config vectors must re-enter the pending queue") + _, err = sqlDB.ExecContext(ctx, ` + UPDATE chat_messages + SET search_tsv = COALESCE(to_tsvector('english', chat_message_search_text(content)), ''::tsvector), + search_tsv_config = 'english' + WHERE id = $1`, eligibleText.ID) + require.NoError(t, err) + require.Empty(t, pendingIDs(ctx, 10)) + // Soft-deleting an unswept row removes it from the queue without a sweep. unswept := newMsg(database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, textContent("unswept deploy row")) require.Equal(t, []int64{unswept.ID}, pendingIDs(ctx, 10)) @@ -2532,7 +2557,8 @@ func TestMigration000543ChatSearchSchemaBehavior(t *testing.T) { // ineligible ones) and assert the search-index predicate filters them. _, err = sqlDB.ExecContext(ctx, ` UPDATE chat_messages - SET search_tsv = COALESCE(to_tsvector('english', chat_message_search_text(content)), ''::tsvector) + SET search_tsv = COALESCE(to_tsvector('english', chat_message_search_text(content)), ''::tsvector), + search_tsv_config = 'english' WHERE chat_id = $1`, chat.ID) require.NoError(t, err) diff --git a/coderd/database/models.go b/coderd/database/models.go index cff3b4c6bf4..2b32ad6a691 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -5139,6 +5139,8 @@ type ChatMessage struct { ReasoningEffort NullChatReasoningEffort `db:"reasoning_effort" json:"reasoning_effort"` // Used for full text search. NULL initially, populated async via background job. SearchTsv interface{} `db:"search_tsv" json:"search_tsv"` + // Text search config that produced search_tsv. NULL means the vector is stale (produced by an unknown config) and the row is pending re-vectorization. Binaries that predate this column cannot set it, so vectors written by an old replica during a rolling upgrade stay pending and are rewritten by an upgraded replica's sweep. + SearchTsvConfig sql.NullString `db:"search_tsv_config" json:"search_tsv_config"` } type ChatModelConfig struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index b7ccde55029..fc7eea36069 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -68,10 +68,16 @@ type sqlcQuerier interface { // buildDigestData in dbpurge.go for the tradeoff rationale. AutoArchiveInactiveChats(ctx context.Context, arg AutoArchiveInactiveChatsParams) ([]AutoArchiveInactiveChatsRow, error) // Backfills chat_messages.search_tsv for pending rows, newest first. - // The WHERE clause must match the predicate of - // idx_chat_messages_search_tsv_pending exactly so the partial index - // serves this query. + // A row is pending when it has never been vectorized (search_tsv IS + // NULL) or when its stored vector was produced with a stale text + // search config (search_tsv_config IS DISTINCT FROM 'english'), e.g. + // rows indexed before the switch to 'english' or rows written by an + // old binary during a rolling upgrade. The WHERE clause must match + // the predicate of idx_chat_messages_search_tsv_pending exactly so + // the partial index serves this query. // NULL means "pending", empty tsvector means "backfilled, no text". + // search_tsv_config records the config that produced the vector and + // is what drains the row from the pending queue. BackfillChatMessagesSearchTsv(ctx context.Context, batchSize int32) (int64, error) BackoffChatDiffStatus(ctx context.Context, arg BackoffChatDiffStatusParams) error // Deletes heartbeat rows for the supplied (chat_id, runner_id) pairs. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index dbf6406c96e..e7fc24949ce 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6960,7 +6960,7 @@ func (q *sqlQuerier) AutoArchiveInactiveChats(ctx context.Context, arg AutoArchi const backfillChatMessagesSearchTsv = `-- name: BackfillChatMessagesSearchTsv :execrows WITH batch AS ( SELECT id FROM chat_messages - WHERE search_tsv IS NULL + WHERE (search_tsv IS NULL OR search_tsv_config IS DISTINCT FROM 'english') AND deleted = false AND visibility IN ('user', 'both') AND role IN ('user', 'assistant') @@ -6969,16 +6969,23 @@ WITH batch AS ( ) UPDATE chat_messages cm SET search_tsv = COALESCE( - to_tsvector('english', chat_message_search_text(cm.content)), - ''::tsvector) + to_tsvector('english', chat_message_search_text(cm.content)), + ''::tsvector), + search_tsv_config = 'english' FROM batch WHERE cm.id = batch.id ` // Backfills chat_messages.search_tsv for pending rows, newest first. -// The WHERE clause must match the predicate of -// idx_chat_messages_search_tsv_pending exactly so the partial index -// serves this query. +// A row is pending when it has never been vectorized (search_tsv IS +// NULL) or when its stored vector was produced with a stale text +// search config (search_tsv_config IS DISTINCT FROM 'english'), e.g. +// rows indexed before the switch to 'english' or rows written by an +// old binary during a rolling upgrade. The WHERE clause must match +// the predicate of idx_chat_messages_search_tsv_pending exactly so +// the partial index serves this query. // NULL means "pending", empty tsvector means "backfilled, no text". +// search_tsv_config records the config that produced the vector and +// is what drains the row from the pending queue. func (q *sqlQuerier) BackfillChatMessagesSearchTsv(ctx context.Context, batchSize int32) (int64, error) { result, err := q.db.ExecContext(ctx, backfillChatMessagesSearchTsv, batchSize) if err != nil { @@ -8008,7 +8015,7 @@ func (q *sqlQuerier) GetChatHeartbeat(ctx context.Context, arg GetChatHeartbeatP const getChatMessageByID = `-- name: GetChatMessageByID :one SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, revision, reasoning_effort, search_tsv + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, revision, reasoning_effort, search_tsv, search_tsv_config FROM chat_messages WHERE @@ -8044,6 +8051,7 @@ func (q *sqlQuerier) GetChatMessageByID(ctx context.Context, id int64) (ChatMess &i.Revision, &i.ReasoningEffort, &i.SearchTsv, + &i.SearchTsvConfig, ) return i, err } @@ -8130,7 +8138,7 @@ func (q *sqlQuerier) GetChatMessageSummariesPerChat(ctx context.Context, created const getChatMessagesByChatID = `-- name: GetChatMessagesByChatID :many SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, revision, reasoning_effort, search_tsv + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, revision, reasoning_effort, search_tsv, search_tsv_config FROM chat_messages WHERE @@ -8184,6 +8192,7 @@ func (q *sqlQuerier) GetChatMessagesByChatID(ctx context.Context, arg GetChatMes &i.Revision, &i.ReasoningEffort, &i.SearchTsv, + &i.SearchTsvConfig, ); err != nil { return nil, err } @@ -8200,7 +8209,7 @@ func (q *sqlQuerier) GetChatMessagesByChatID(ctx context.Context, arg GetChatMes const getChatMessagesByChatIDAscPaginated = `-- name: GetChatMessagesByChatIDAscPaginated :many SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, revision, reasoning_effort, search_tsv + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, revision, reasoning_effort, search_tsv, search_tsv_config FROM chat_messages WHERE @@ -8254,6 +8263,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDAscPaginated(ctx context.Context, ar &i.Revision, &i.ReasoningEffort, &i.SearchTsv, + &i.SearchTsvConfig, ); err != nil { return nil, err } @@ -8270,7 +8280,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDAscPaginated(ctx context.Context, ar const getChatMessagesByChatIDDescPaginated = `-- name: GetChatMessagesByChatIDDescPaginated :many SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, revision, reasoning_effort, search_tsv + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, revision, reasoning_effort, search_tsv, search_tsv_config FROM chat_messages WHERE @@ -8337,6 +8347,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDDescPaginated(ctx context.Context, a &i.Revision, &i.ReasoningEffort, &i.SearchTsv, + &i.SearchTsvConfig, ); err != nil { return nil, err } @@ -8353,7 +8364,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDDescPaginated(ctx context.Context, a const getChatMessagesByRevisionForStream = `-- name: GetChatMessagesByRevisionForStream :many SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, revision, reasoning_effort, search_tsv + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, revision, reasoning_effort, search_tsv, search_tsv_config FROM chat_messages WHERE @@ -8404,6 +8415,7 @@ func (q *sqlQuerier) GetChatMessagesByRevisionForStream(ctx context.Context, arg &i.Revision, &i.ReasoningEffort, &i.SearchTsv, + &i.SearchTsvConfig, ); err != nil { return nil, err } @@ -8435,7 +8447,7 @@ WITH latest_compressed_summary AS ( 1 ) SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, revision, reasoning_effort, search_tsv + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, revision, reasoning_effort, search_tsv, search_tsv_config FROM chat_messages WHERE @@ -8511,6 +8523,7 @@ func (q *sqlQuerier) GetChatMessagesForPromptByChatID(ctx context.Context, chatI &i.Revision, &i.ReasoningEffort, &i.SearchTsv, + &i.SearchTsvConfig, ); err != nil { return nil, err } @@ -9742,7 +9755,7 @@ func (q *sqlQuerier) GetDatabaseNow(ctx context.Context) (time.Time, error) { const getLastChatMessageByRole = `-- name: GetLastChatMessageByRole :one SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, revision, reasoning_effort, search_tsv + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, revision, reasoning_effort, search_tsv, search_tsv_config FROM chat_messages WHERE @@ -9790,6 +9803,7 @@ func (q *sqlQuerier) GetLastChatMessageByRole(ctx context.Context, arg GetLastCh &i.Revision, &i.ReasoningEffort, &i.SearchTsv, + &i.SearchTsvConfig, ) return i, err } @@ -10306,9 +10320,9 @@ inserted AS ( ($16::boolean[])[allocated.ord], NULLIF(($17::bigint[])[allocated.ord], 0) FROM allocated - RETURNING id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, revision, reasoning_effort, search_tsv + RETURNING id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, revision, reasoning_effort, search_tsv, search_tsv_config ) -SELECT id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, revision, reasoning_effort, search_tsv +SELECT id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, revision, reasoning_effort, search_tsv, search_tsv_config FROM inserted ORDER BY id ` @@ -10358,6 +10372,7 @@ type InsertChatMessagesRow struct { Revision int64 `db:"revision" json:"revision"` ReasoningEffort NullChatReasoningEffort `db:"reasoning_effort" json:"reasoning_effort"` SearchTsv interface{} `db:"search_tsv" json:"search_tsv"` + SearchTsvConfig sql.NullString `db:"search_tsv_config" json:"search_tsv_config"` } // Returns the inserted rows in input array order. Ids are allocated before the @@ -10415,6 +10430,7 @@ func (q *sqlQuerier) InsertChatMessages(ctx context.Context, arg InsertChatMessa &i.Revision, &i.ReasoningEffort, &i.SearchTsv, + &i.SearchTsvConfig, ); err != nil { return nil, err } diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 9ee21b163e4..affaaf4ba8c 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -316,12 +316,16 @@ WHERE -- name: BackfillChatMessagesSearchTsv :execrows -- Backfills chat_messages.search_tsv for pending rows, newest first. --- The WHERE clause must match the predicate of --- idx_chat_messages_search_tsv_pending exactly so the partial index --- serves this query. +-- A row is pending when it has never been vectorized (search_tsv IS +-- NULL) or when its stored vector was produced with a stale text +-- search config (search_tsv_config IS DISTINCT FROM 'english'), e.g. +-- rows indexed before the switch to 'english' or rows written by an +-- old binary during a rolling upgrade. The WHERE clause must match +-- the predicate of idx_chat_messages_search_tsv_pending exactly so +-- the partial index serves this query. WITH batch AS ( SELECT id FROM chat_messages - WHERE search_tsv IS NULL + WHERE (search_tsv IS NULL OR search_tsv_config IS DISTINCT FROM 'english') AND deleted = false AND visibility IN ('user', 'both') AND role IN ('user', 'assistant') @@ -330,9 +334,12 @@ WITH batch AS ( ) UPDATE chat_messages cm -- NULL means "pending", empty tsvector means "backfilled, no text". +-- search_tsv_config records the config that produced the vector and +-- is what drains the row from the pending queue. SET search_tsv = COALESCE( - to_tsvector('english', chat_message_search_text(cm.content)), - ''::tsvector) + to_tsvector('english', chat_message_search_text(cm.content)), + ''::tsvector), + search_tsv_config = 'english' FROM batch WHERE cm.id = batch.id; -- name: GetChatByID :one diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index d9f1e16f8f6..4607e24460a 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -280,6 +280,8 @@ Message revision triggers depend on the transition invariant that `snapshot_vers A `BEFORE INSERT` trigger assigns the current chat `snapshot_version` to the inserted message row and records the same value as the chat's latest history version: + + ```sql CREATE FUNCTION set_chat_message_revision() RETURNS trigger AS $$ diff --git a/coderd/x/chatd/chatstate/trigger_test.go b/coderd/x/chatd/chatstate/trigger_test.go index fc86811b88c..004e1704648 100644 --- a/coderd/x/chatd/chatstate/trigger_test.go +++ b/coderd/x/chatd/chatstate/trigger_test.go @@ -304,10 +304,11 @@ func TestSearchTsvBackfillDoesNotTouchChatState(t *testing.T) { require.NotEqual(t, bumped.SnapshotVersion, bumped.HistoryVersion, "snapshot bump leaves history_version trailing") - // Backfill-shaped UPDATE: only search_tsv changes. + // Backfill-shaped UPDATE: only search_tsv and search_tsv_config change. _, err = tf.sqlDB.ExecContext(ctx, ` UPDATE chat_messages - SET search_tsv = COALESCE(to_tsvector('english', chat_message_search_text(content)), ''::tsvector) + SET search_tsv = COALESCE(to_tsvector('english', chat_message_search_text(content)), ''::tsvector), + search_tsv_config = 'english' WHERE id = $1 `, target.ID) require.NoError(t, err) From 4290facbd533fb483bf09eacf10cddba375c6ff1 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Thu, 20 Aug 2026 19:22:14 +0000 Subject: [PATCH 03/16] fix(coderd): renumber chat search migration to 000580 Migration 000574 was taken on main by 000574_mcp_server_configs_organization_id after this branch was cut, which made the merge commit fail to migrate with a duplicate migration file error. --- ...nfig.down.sql => 000580_chat_search_english_config.down.sql} | 0 ...h_config.up.sql => 000580_chat_search_english_config.up.sql} | 0 coderd/x/chatd/ARCHITECTURE.md | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) rename coderd/database/migrations/{000574_chat_search_english_config.down.sql => 000580_chat_search_english_config.down.sql} (100%) rename coderd/database/migrations/{000574_chat_search_english_config.up.sql => 000580_chat_search_english_config.up.sql} (100%) diff --git a/coderd/database/migrations/000574_chat_search_english_config.down.sql b/coderd/database/migrations/000580_chat_search_english_config.down.sql similarity index 100% rename from coderd/database/migrations/000574_chat_search_english_config.down.sql rename to coderd/database/migrations/000580_chat_search_english_config.down.sql diff --git a/coderd/database/migrations/000574_chat_search_english_config.up.sql b/coderd/database/migrations/000580_chat_search_english_config.up.sql similarity index 100% rename from coderd/database/migrations/000574_chat_search_english_config.up.sql rename to coderd/database/migrations/000580_chat_search_english_config.up.sql diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index 4607e24460a..d8a6ab5e16b 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -280,7 +280,7 @@ Message revision triggers depend on the transition invariant that `snapshot_vers A `BEFORE INSERT` trigger assigns the current chat `snapshot_version` to the inserted message row and records the same value as the chat's latest history version: - + ```sql CREATE FUNCTION set_chat_message_revision() From 930e3df3a36ffb0338f25ae8abc1ee9322255c13 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Thu, 20 Aug 2026 20:21:28 +0000 Subject: [PATCH 04/16] fix(coderd/database): query stale chat search vectors with their own config During the post-migration drain window, message vectors not yet rewritten by the sweep still hold 'simple' lexemes, and an old replica in a rolling upgrade can add more. Querying those rows with an 'english' tsquery misses them even for the exact word form users typed before the config switch (stored lexeme 'refactoring' never matches the stemmed query 'refactor'). GetChats now matches each vector with the config that produced it: rows stamped 'english' use the stemmed query, rows with a NULL search_tsv_config use the 'simple' query, preserving their pre-migration matching until the sweep rewrites them. Both arms use constant tsqueries, verified via EXPLAIN to plan as a BitmapOr of two scans over idx_chat_messages_search_tsv; once the backlog drains the 'simple' arm matches no rows. --- coderd/database/querier_test.go | 19 ++++++++++++++++++- coderd/database/queries.sql.go | 17 ++++++++++++++++- coderd/database/queries/chats.sql | 17 ++++++++++++++++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 584fa8113fd..6ef9992f3dd 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -18086,6 +18086,18 @@ func TestGetChatsSearch(t *testing.T) { pendingChat := createRoot("plain four") insertMsg(pendingChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "elasticsearch indexing") + // Simulates the post-migration drain window: a vector produced with + // the 'simple' config whose search_tsv_config was never stamped + // (pre-migration backfill or an old binary mid rolling upgrade). + // Such rows must keep their pre-migration exact-form matching until + // the sweep rewrites them. + staleChat := createRoot("plain stale") + staleMsg := insertMsg(staleChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "refactoring codebase") + _, err = sqlDB.ExecContext(ctx, + `UPDATE chat_messages SET search_tsv = to_tsvector('simple', 'refactoring codebase'), search_tsv_config = NULL WHERE id = $1`, + staleMsg.ID) + require.NoError(t, err) + // Prove role/visibility predicates exclude rows even when search_tsv // is set. _, err = sqlDB.ExecContext(ctx, @@ -18100,7 +18112,7 @@ func TestGetChatsSearch(t *testing.T) { titleChat.ID, archivedChat.ID, prTitleChat.ID, mergedChat.ID, msgChat.ID, assistantMsgChat.ID, userVisMsgChat.ID, assistantUserVisMsgChat.ID, deletedMsgChat.ID, childParent.ID, - ineligibleChat.ID, pendingChat.ID, + ineligibleChat.ID, pendingChat.ID, staleChat.ID, } tests := []struct { @@ -18118,6 +18130,11 @@ func TestGetChatsSearch(t *testing.T) { {"PRTitle/StemmedMatch", database.GetChatsParams{Search: "authenticating"}, []uuid.UUID{prTitleChat.ID, mergedChat.ID}}, {"Message/Match", database.GetChatsParams{Search: "kubernetes restart"}, []uuid.UUID{msgChat.ID}}, {"Message/StemmedMatch", database.GetChatsParams{Search: "restarting clusters"}, []uuid.UUID{msgChat.ID}}, + // Stale 'simple' vectors are queried with their own config: the + // exact form matches like before the migration, the stemmed form + // does not until the sweep rewrites the row. + {"Message/StaleVectorExactFormMatch", database.GetChatsParams{Search: "refactoring codebase"}, []uuid.UUID{staleChat.ID}}, + {"Message/StaleVectorNoStemming", database.GetChatsParams{Search: "refactor"}, nil}, {"Message/AssistantRoleMatch", database.GetChatsParams{Search: "grafana tuning"}, []uuid.UUID{assistantMsgChat.ID}}, {"Message/UserVisibilityMatch", database.GetChatsParams{Search: "vault rotation"}, []uuid.UUID{userVisMsgChat.ID}}, {"Message/AssistantUserVisibilityMatch", database.GetChatsParams{Search: "redis eviction"}, []uuid.UUID{assistantUserVisMsgChat.ID}}, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 0b42ff792bc..a284ad4e2ee 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -9197,7 +9197,22 @@ WHERE AND cm.deleted = false AND cm.visibility IN ('user', 'both') AND cm.role IN ('user', 'assistant') - AND cm.search_tsv @@ websearch_to_tsquery('english', $16) + -- Match each vector with the config that produced + -- it. Rows not yet re-vectorized by the sweep + -- (search_tsv_config IS NULL) hold 'simple' + -- lexemes, so an 'english' tsquery would miss them + -- (stored lexeme 'refactoring' never matches the + -- stemmed query 'refactor'). Querying stale rows + -- with 'simple' preserves their pre-migration + -- matching until the sweep rewrites them; once the + -- backlog drains the second arm matches no rows. + -- Both arms use constant tsqueries so the planner + -- can serve the OR as a bitmap over + -- idx_chat_messages_search_tsv. + AND ( + (cm.search_tsv_config = 'english' AND cm.search_tsv @@ websearch_to_tsquery('english', $16)) + OR (cm.search_tsv_config IS NULL AND cm.search_tsv @@ websearch_to_tsquery('simple', $16)) + ) ) -- Skip an explicit pr_number lookup unless the search is a valid bigint. OR CASE diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index affaaf4ba8c..511ecee4bfb 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -713,7 +713,22 @@ WHERE AND cm.deleted = false AND cm.visibility IN ('user', 'both') AND cm.role IN ('user', 'assistant') - AND cm.search_tsv @@ websearch_to_tsquery('english', @search) + -- Match each vector with the config that produced + -- it. Rows not yet re-vectorized by the sweep + -- (search_tsv_config IS NULL) hold 'simple' + -- lexemes, so an 'english' tsquery would miss them + -- (stored lexeme 'refactoring' never matches the + -- stemmed query 'refactor'). Querying stale rows + -- with 'simple' preserves their pre-migration + -- matching until the sweep rewrites them; once the + -- backlog drains the second arm matches no rows. + -- Both arms use constant tsqueries so the planner + -- can serve the OR as a bitmap over + -- idx_chat_messages_search_tsv. + AND ( + (cm.search_tsv_config = 'english' AND cm.search_tsv @@ websearch_to_tsquery('english', @search)) + OR (cm.search_tsv_config IS NULL AND cm.search_tsv @@ websearch_to_tsquery('simple', @search)) + ) ) -- Skip an explicit pr_number lookup unless the search is a valid bigint. OR CASE From 2f2580d810aae51cfdae4d09d90e3f6b5ad3e9eb Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Mon, 24 Aug 2026 13:24:23 +0000 Subject: [PATCH 05/16] fix(coderd/database): reset english vectors in down migration for parent sweep On rollback, rows the upgraded sweep re-vectorized held 'english' lexemes while the parent binary queries with 'simple' and its backfill only selects search_tsv IS NULL rows, so those rows were never repaired. The down migration now resets search_tsv to NULL for rows stamped search_tsv_config = 'english' before dropping the column, handing exactly the affected set back to the parent's bounded dbpurge sweep. Rows never re-vectorized keep their valid 'simple' vectors. The reset runs while the replacement trigger functions are still installed, so it does not advance message revisions or chat history_version. --- ...000580_chat_search_english_config.down.sql | 29 +++-- coderd/database/migrations/migrate_test.go | 102 ++++++++++++++++++ 2 files changed, 123 insertions(+), 8 deletions(-) diff --git a/coderd/database/migrations/000580_chat_search_english_config.down.sql b/coderd/database/migrations/000580_chat_search_english_config.down.sql index fcdb257dce8..aff1668caec 100644 --- a/coderd/database/migrations/000580_chat_search_english_config.down.sql +++ b/coderd/database/migrations/000580_chat_search_english_config.down.sql @@ -1,10 +1,4 @@ -- Revert chat full-text search to the 'simple' text search config. --- Stored 'english' vectors in chat_messages.search_tsv are left in --- place: rewriting them would be the same full-table UPDATE the up --- migration avoids, and there is no config column left to drive an --- incremental re-vectorization. Searches against those rows degrade --- to exact-lexeme matches until the rows are deleted or re-indexed by --- other means. DROP INDEX idx_chats_title_fts; @@ -18,10 +12,29 @@ CREATE INDEX idx_chat_diff_statuses_pr_title_fts ON chat_diff_statuses USING GIN COMMENT ON INDEX idx_chat_diff_statuses_pr_title_fts IS 'Used for full text search. Defined over all rows of the chat_diff_statuses table.'; --- The pending index predicate references search_tsv_config, so it must --- be dropped before the column. +-- Dropped before the reset below so the UPDATE does not maintain an +-- index that is about to be recreated with the old predicate anyway. DROP INDEX idx_chat_messages_search_tsv_pending; +-- Hand rows re-vectorized with 'english' back to the parent version's +-- bounded dbpurge sweep. The parent backfill only selects rows with +-- search_tsv IS NULL, so 'english' vectors must be reset to NULL to +-- re-enter its pending queue; the parent sweep then rewrites them with +-- 'simple' incrementally, newest first. Rows never re-vectorized +-- (search_tsv_config IS NULL) still hold valid 'simple' lexemes and +-- are left untouched. The reset size is proportional to how much of +-- the backlog the upgraded sweep processed before the rollback, so a +-- prompt rollback rewrites a small set; only a rollback after a full +-- drain approaches a full-table update, which is unavoidable because +-- the parent job has no other way to find these rows. +-- +-- This UPDATE runs while the replacement trigger functions (which +-- exclude search_tsv and search_tsv_config from change comparisons) +-- are still installed, so it does not advance message revisions or +-- chat history_version. The original trigger functions are restored +-- below, after this statement. +UPDATE chat_messages SET search_tsv = NULL WHERE search_tsv_config = 'english'; + ALTER TABLE chat_messages DROP COLUMN search_tsv_config; CREATE INDEX idx_chat_messages_search_tsv_pending ON chat_messages USING btree (id DESC) diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index fb788793e85..7e3b4dfa72e 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -2597,6 +2597,108 @@ func TestMigration000543ChatSearchSchemaBehavior(t *testing.T) { toolMsg.ID, modelOnly.ID, deletedMsg.ID) } +func TestMigration000580ChatSearchEnglishConfigDown(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + + sqlDB := testSQLDB(t) + require.NoError(t, migrations.Up(sqlDB)) + db := database.New(sqlDB) + ctx := testutil.Context(t, testutil.WaitLong) + + org := dbgen.Organization(t, db, database.Organization{}) + owner := dbgen.User(t, db, database.User{}) + _ = dbgen.ChatProvider(t, db, database.ChatProvider{Provider: "openai", DisplayName: "OpenAI"}) + modelCfg := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true}, + IsDefault: true, + }) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: owner.ID, + LastModelConfigID: modelCfg.ID, + }) + + newMsg := func(text string) database.ChatMessage { + return dbgen.ChatMessage(t, db, database.ChatMessage{ + ChatID: chat.ID, + CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true}, + Role: database.ChatMessageRoleUser, + Visibility: database.ChatMessageVisibilityBoth, + Content: pqtype.NullRawMessage{RawMessage: []byte(`[{"type":"text","text":"` + text + `"}]`), Valid: true}, + }) + } + + // A row the upgraded sweep re-vectorized, a stale row it never + // reached, and a row that was still pending. + rewritten := newMsg("rewritten with english") + stale := newMsg("stale simple vector") + pending := newMsg("still pending") + + _, err := sqlDB.ExecContext(ctx, ` + UPDATE chat_messages + SET search_tsv = to_tsvector('english', chat_message_search_text(content)), + search_tsv_config = 'english' + WHERE id = $1`, rewritten.ID) + require.NoError(t, err) + _, err = sqlDB.ExecContext(ctx, ` + UPDATE chat_messages + SET search_tsv = to_tsvector('simple', chat_message_search_text(content)), + search_tsv_config = NULL + WHERE id = $1`, stale.ID) + require.NoError(t, err) + + var revisionBefore int64 + err = sqlDB.QueryRowContext(ctx, + `SELECT revision FROM chat_messages WHERE id = $1`, rewritten.ID, + ).Scan(&revisionBefore) + require.NoError(t, err) + + downSQL, err := os.ReadFile("000580_chat_search_english_config.down.sql") + require.NoError(t, err) + _, err = sqlDB.ExecContext(ctx, string(downSQL)) + require.NoError(t, err) + + // The 'english' row is handed back to the parent sweep: NULL vector + // re-enters the parent's search_tsv IS NULL pending queue. + tsvState := func(id int64) (isNull bool, isSimple bool) { + err := sqlDB.QueryRowContext(ctx, ` + SELECT search_tsv IS NULL, + search_tsv IS NOT DISTINCT FROM to_tsvector('simple', chat_message_search_text(content)) + FROM chat_messages WHERE id = $1`, id, + ).Scan(&isNull, &isSimple) + require.NoError(t, err) + return isNull, isSimple + } + isNull, _ := tsvState(rewritten.ID) + require.True(t, isNull, "english-stamped rows must be reset so the parent sweep reindexes them") + isNull, isSimple := tsvState(stale.ID) + require.False(t, isNull, "rows never re-vectorized must keep their vector") + require.True(t, isSimple, "rows never re-vectorized must keep their 'simple' lexemes") + isNull, _ = tsvState(pending.ID) + require.True(t, isNull, "pending rows stay pending") + + // The reset ran under the replacement trigger functions, so it must + // not have advanced the message revision. + var revisionAfter int64 + err = sqlDB.QueryRowContext(ctx, + `SELECT revision FROM chat_messages WHERE id = $1`, rewritten.ID, + ).Scan(&revisionAfter) + require.NoError(t, err) + require.Equal(t, revisionBefore, revisionAfter, "down migration reset must not bump revisions") + + var columnCount int + err = sqlDB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM information_schema.columns + WHERE table_name = 'chat_messages' AND column_name = 'search_tsv_config'`, + ).Scan(&columnCount) + require.NoError(t, err) + require.Zero(t, columnCount, "search_tsv_config must be dropped") +} + func TestMigration000556UserSecretsEnabled(t *testing.T) { t.Parallel() From 74dbd5c92e5c2236150ea70358c03f3cf80ffe67 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Mon, 24 Aug 2026 13:46:08 +0000 Subject: [PATCH 06/16] fix(coderd): renumber chat search migration to 000585 Migration 000580 was taken on main after this branch was last synced, which made the merge commit fail to migrate with a duplicate migration file error. --- ...ig.down.sql => 000585_chat_search_english_config.down.sql} | 0 ...config.up.sql => 000585_chat_search_english_config.up.sql} | 0 coderd/database/migrations/migrate_test.go | 4 ++-- coderd/x/chatd/ARCHITECTURE.md | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename coderd/database/migrations/{000580_chat_search_english_config.down.sql => 000585_chat_search_english_config.down.sql} (100%) rename coderd/database/migrations/{000580_chat_search_english_config.up.sql => 000585_chat_search_english_config.up.sql} (100%) diff --git a/coderd/database/migrations/000580_chat_search_english_config.down.sql b/coderd/database/migrations/000585_chat_search_english_config.down.sql similarity index 100% rename from coderd/database/migrations/000580_chat_search_english_config.down.sql rename to coderd/database/migrations/000585_chat_search_english_config.down.sql diff --git a/coderd/database/migrations/000580_chat_search_english_config.up.sql b/coderd/database/migrations/000585_chat_search_english_config.up.sql similarity index 100% rename from coderd/database/migrations/000580_chat_search_english_config.up.sql rename to coderd/database/migrations/000585_chat_search_english_config.up.sql diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index aae1c7043fa..c0fce1bc403 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -2583,7 +2583,7 @@ func TestMigration000543ChatSearchSchemaBehavior(t *testing.T) { toolMsg.ID, modelOnly.ID, deletedMsg.ID) } -func TestMigration000580ChatSearchEnglishConfigDown(t *testing.T) { +func TestMigration000585ChatSearchEnglishConfigDown(t *testing.T) { t.Parallel() if testing.Short() { t.SkipNow() @@ -2643,7 +2643,7 @@ func TestMigration000580ChatSearchEnglishConfigDown(t *testing.T) { ).Scan(&revisionBefore) require.NoError(t, err) - downSQL, err := os.ReadFile("000580_chat_search_english_config.down.sql") + downSQL, err := os.ReadFile("000585_chat_search_english_config.down.sql") require.NoError(t, err) _, err = sqlDB.ExecContext(ctx, string(downSQL)) require.NoError(t, err) diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index 114c450d8d8..cc2f693eaf2 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -280,7 +280,7 @@ Message revision triggers depend on the transition invariant that `snapshot_vers A `BEFORE INSERT` trigger assigns the current chat `snapshot_version` to the inserted message row and records the same value as the chat's latest history version: - + ```sql CREATE FUNCTION set_chat_message_revision() From e7f7fc34a92cd50e36fe3ea7cd382c64a8d09059 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Mon, 24 Aug 2026 15:34:47 +0000 Subject: [PATCH 07/16] fix(coderd/database): reindex stale chat search vectors without an index rebuild The migration previously dropped and recreated idx_chat_messages_search_tsv_pending with a wider predicate covering stale-config rows. A non-concurrent CREATE INDEX takes a SHARE lock on chat_messages and initially covers every eligible row, so message writes from still-running replicas would block for the whole build during a rolling upgrade. The migration now leaves chat_messages indexes untouched. The pending queue keeps its original search_tsv IS NULL predicate for the permanent workload (new messages), and a new deliberately unindexed sweep query, ReindexStaleChatMessagesSearchTsv, drains the one-time stale backlog: ORDER BY id DESC with LIMIT walks the primary key backwards and terminates early while stale rows are dense, which is the entire drain. Once a pass returns fewer rows than the batch size the scan provably reached the end of the table and dbpurge latches the stale pass off for the process lifetime; upgraded binaries always stamp search_tsv_config, so the backlog can never refill. A stale row written after the latch (old replica racing the tail of a rolling upgrade) stays correctly searchable via its recorded config and is repaired by the single stale pass after the next process restart. --- coderd/database/dbauthz/dbauthz.go | 7 ++ coderd/database/dbauthz/dbauthz_test.go | 4 ++ coderd/database/dbmetrics/querymetrics.go | 8 +++ coderd/database/dbmock/dbmock.go | 15 +++++ coderd/database/dbpurge/dbpurge.go | 43 ++++++++++++ coderd/database/dbpurge/dbpurge_test.go | 67 +++++++++++++------ coderd/database/dump.sql | 4 +- ...000585_chat_search_english_config.down.sql | 27 +++----- .../000585_chat_search_english_config.up.sql | 28 ++------ coderd/database/migrations/migrate_test.go | 43 ++++++++---- coderd/database/querier.go | 30 ++++++--- coderd/database/queries.sql.go | 58 +++++++++++++--- coderd/database/queries/chats.sql | 49 +++++++++++--- 13 files changed, 277 insertions(+), 106 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index f31434ed08d..85429080517 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -7187,6 +7187,13 @@ func (q *querier) RegisterWorkspaceProxy(ctx context.Context, arg database.Regis return updateWithReturn(q.log, q.auth, fetch, q.db.RegisterWorkspaceProxy)(ctx, arg) } +func (q *querier) ReindexStaleChatMessagesSearchTsv(ctx context.Context, batchSize int32) (int64, error) { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceChat); err != nil { + return 0, err + } + return q.db.ReindexStaleChatMessagesSearchTsv(ctx, batchSize) +} + func (q *querier) ReleaseExternalAuthLinkRefreshLease(ctx context.Context, arg database.ReleaseExternalAuthLinkRefreshLeaseParams) error { fetch := func(ctx context.Context, arg database.ReleaseExternalAuthLinkRefreshLeaseParams) (database.ExternalAuthLink, error) { return q.db.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{UserID: arg.UserID, ProviderID: arg.ProviderID}) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index bea464f0b72..3c2495f9621 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1079,6 +1079,10 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().BackfillChatMessagesSearchTsv(gomock.Any(), int32(100)).Return(int64(0), nil).AnyTimes() check.Args(int32(100)).Asserts(rbac.ResourceChat, policy.ActionUpdate) })) + s.Run("ReindexStaleChatMessagesSearchTsv", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().ReindexStaleChatMessagesSearchTsv(gomock.Any(), int32(100)).Return(int64(0), nil).AnyTimes() + check.Args(int32(100)).Asserts(rbac.ResourceChat, policy.ActionUpdate) + })) s.Run("GetChatRetentionDays", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().GetChatRetentionDays(gomock.Any()).Return(int32(30), nil).AnyTimes() check.Args().Asserts() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index a3c4d42de19..c96e6c9d9ff 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -5024,6 +5024,14 @@ func (m queryMetricsStore) RegisterWorkspaceProxy(ctx context.Context, arg datab return r0, r1 } +func (m queryMetricsStore) ReindexStaleChatMessagesSearchTsv(ctx context.Context, batchSize int32) (int64, error) { + start := time.Now() + r0, r1 := m.s.ReindexStaleChatMessagesSearchTsv(ctx, batchSize) + m.queryLatencies.WithLabelValues("ReindexStaleChatMessagesSearchTsv").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ReindexStaleChatMessagesSearchTsv").Inc() + return r0, r1 +} + func (m queryMetricsStore) ReleaseExternalAuthLinkRefreshLease(ctx context.Context, arg database.ReleaseExternalAuthLinkRefreshLeaseParams) error { start := time.Now() r0 := m.s.ReleaseExternalAuthLinkRefreshLease(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index f4476bfe997..03434beefb4 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -9549,6 +9549,21 @@ func (mr *MockStoreMockRecorder) RegisterWorkspaceProxy(ctx, arg any) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterWorkspaceProxy", reflect.TypeOf((*MockStore)(nil).RegisterWorkspaceProxy), ctx, arg) } +// ReindexStaleChatMessagesSearchTsv mocks base method. +func (m *MockStore) ReindexStaleChatMessagesSearchTsv(ctx context.Context, batchSize int32) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReindexStaleChatMessagesSearchTsv", ctx, batchSize) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReindexStaleChatMessagesSearchTsv indicates an expected call of ReindexStaleChatMessagesSearchTsv. +func (mr *MockStoreMockRecorder) ReindexStaleChatMessagesSearchTsv(ctx, batchSize any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReindexStaleChatMessagesSearchTsv", reflect.TypeOf((*MockStore)(nil).ReindexStaleChatMessagesSearchTsv), ctx, batchSize) +} + // ReleaseExternalAuthLinkRefreshLease mocks base method. func (m *MockStore) ReleaseExternalAuthLinkRefreshLease(ctx context.Context, arg database.ReleaseExternalAuthLinkRefreshLeaseParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dbpurge/dbpurge.go b/coderd/database/dbpurge/dbpurge.go index 7c284339d0e..47518461899 100644 --- a/coderd/database/dbpurge/dbpurge.go +++ b/coderd/database/dbpurge/dbpurge.go @@ -181,6 +181,10 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time. chatConfigErr := errors.Join(chatRetentionErr, chatDebugRetentionErr) + // Set inside the transaction closure, applied to the instance only + // after the transaction commits. + var staleDrained bool + // Start a transaction to grab advisory lock, we don't want to run // multiple purges at the same time (multiple replicas). err := db.InTx(func(tx database.Store) error { @@ -354,6 +358,38 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time. } } + // Rewrite vectors produced with a stale text search config (rows + // indexed before the switch to 'english', plus rows written by an + // old binary during a rolling upgrade). This backlog is finite: + // upgraded binaries always stamp search_tsv_config, so once the + // scan comes up short the queue can never refill and the query is + // skipped for the process lifetime. A pass returning fewer rows + // than the batch size proves the unindexed scan reached the end of + // the table, so latching on it avoids one extra full walk. If an + // old replica writes a stale row after the latch is set, the row + // stays correctly searchable via its recorded config (GetChats + // matches each vector with the config that produced it) and is + // rewritten after the next process restart re-runs one pass. + // + // staleDrained is committed to the instance only after the + // transaction succeeds; a rollback discards the batch updates, so + // latching inside the transaction could strand stale rows. + var reindexedChatSearchRows int64 + if !i.chatSearchStaleDrained { + for range i.chatSearchBackfillMaxBatches { + n, err := tx.ReindexStaleChatMessagesSearchTsv(ctx, i.chatSearchBackfillBatchSize) + if err != nil { + return xerrors.Errorf("reindex stale chat_messages.search_tsv: %w", err) + } + reindexedChatSearchRows += n + if n < int64(i.chatSearchBackfillBatchSize) { + staleDrained = true + break + } + } + } + backfilledChatSearchRows += reindexedChatSearchRows + i.logger.Debug(ctx, "purged old database entries", slog.F("workspace_agent_logs", purgedWorkspaceAgentLogs), slog.F("expired_api_keys", expiredAPIKeys), @@ -399,6 +435,9 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time. if err != nil { return err } + if staleDrained { + i.chatSearchStaleDrained = true + } // Surface the deferred chat-config error so doTick records // the failed iteration metric. @@ -420,6 +459,10 @@ type instance struct { chatSearchRowsBackfilled prometheus.Counter chatSearchBackfillBatchSize int32 chatSearchBackfillMaxBatches int + // chatSearchStaleDrained latches off the stale-config reindex scan + // once it observes an empty (or final partial) pass. Only doTick's + // single goroutine touches it. + chatSearchStaleDrained bool } func (i *instance) Close() error { diff --git a/coderd/database/dbpurge/dbpurge_test.go b/coderd/database/dbpurge/dbpurge_test.go index 6fed9ab53b9..36d5482b94d 100644 --- a/coderd/database/dbpurge/dbpurge_test.go +++ b/coderd/database/dbpurge/dbpurge_test.go @@ -3093,7 +3093,7 @@ func TestBackfillChatMessagesSearchTsv(t *testing.T) { var count int err := rawDB.QueryRowContext(ctx, ` SELECT COUNT(*) FROM chat_messages - WHERE (search_tsv IS NULL OR search_tsv_config IS DISTINCT FROM 'english') + WHERE search_tsv IS NULL AND deleted = false AND visibility IN ('user', 'both') AND role IN ('user', 'assistant')`).Scan(&count) @@ -3297,31 +3297,60 @@ func TestBackfillChatMessagesSearchTsv(t *testing.T) { logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) deps := setupDeps(t, db) - msg := createMessage(t, db, deps, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, textContent("refactoring the deployment")) + countStale := func() int { + var count int + err := rawDB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM chat_messages + WHERE search_tsv IS NOT NULL + AND search_tsv_config IS DISTINCT FROM 'english' + AND deleted = false + AND visibility IN ('user', 'both') + AND role IN ('user', 'assistant')`).Scan(&count) + require.NoError(t, err) + return count + } + makeStale := func(id int64) { + // Simulates a vector produced before the 'english' switch or by + // a binary that predates search_tsv_config (e.g. an old replica + // winning the dbpurge lock mid rolling upgrade). + _, err := rawDB.ExecContext(ctx, ` + UPDATE chat_messages + SET search_tsv = to_tsvector('simple', chat_message_search_text(content)), + search_tsv_config = NULL + WHERE id = $1`, id) + require.NoError(t, err) + } + + preexisting := createMessage(t, db, deps, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, textContent("refactoring the deployment")) + makeStale(preexisting.ID) + require.Equal(t, 1, countStale()) tick := awaitDoTicks(ctx, t, clk, 2) closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), dbpurge.WithClock(clk)) - defer closer.Close() + // The first tick rewrites the stale backlog with 'english' and + // latches off the stale scan for the process lifetime. tick() - requireTsvFor(ctx, t, rawDB, msg.ID, "refactoring the deployment") - - // Simulate a binary that predates search_tsv_config (e.g. an old - // replica winning the dbpurge lock mid rolling upgrade): it writes - // a 'simple' vector and cannot stamp the config column. The row - // must re-enter the pending queue and be rewritten, not stay - // wrongly indexed forever. - _, err := rawDB.ExecContext(ctx, ` - UPDATE chat_messages - SET search_tsv = to_tsvector('simple', chat_message_search_text(content)), - search_tsv_config = NULL - WHERE id = $1`, msg.ID) - require.NoError(t, err) - require.Equal(t, 1, countPending(ctx, t, rawDB), "stale-config vector should be pending again") + requireTsvFor(ctx, t, rawDB, preexisting.ID, "refactoring the deployment") + require.Zero(t, countStale()) + + // A stale row appearing after the latch (old replica racing the + // tail of a rolling upgrade) is intentionally not rewritten by + // this process; it stays correctly searchable via its recorded + // config until a restart re-runs one stale pass. + makeStale(preexisting.ID) + tick() + require.Equal(t, 1, countStale(), "stale scan must stay latched off after drain") + require.NoError(t, closer.Close()) + // A new dbpurge instance (process restart) re-runs one stale pass + // and repairs the row. + tick = awaitDoTicks(ctx, t, clk, 1) + closer = dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), dbpurge.WithClock(clk)) + defer closer.Close() tick() - requireTsvFor(ctx, t, rawDB, msg.ID, "refactoring the deployment") - require.Zero(t, countPending(ctx, t, rawDB)) + requireTsvFor(ctx, t, rawDB, preexisting.ID, "refactoring the deployment") + require.Zero(t, countStale()) }) //nolint:paralleltest // It uses LockIDDBPurge. diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 34d354b5fbb..8ad0121bdd4 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -4922,9 +4922,7 @@ CREATE INDEX idx_chat_messages_search_tsv ON chat_messages USING gin (search_tsv COMMENT ON INDEX idx_chat_messages_search_tsv IS 'Partial index over chat_messages used for populating search_tsv in the background. Only defined over ''searchable'' rows of chat_messages where search_tsv is NULL.'; -CREATE INDEX idx_chat_messages_search_tsv_pending ON chat_messages USING btree (id DESC) WHERE (((search_tsv IS NULL) OR (search_tsv_config IS DISTINCT FROM 'english'::text)) AND (deleted = false) AND (visibility = ANY (ARRAY['user'::chat_message_visibility, 'both'::chat_message_visibility])) AND (role = ANY (ARRAY['user'::chat_message_role, 'assistant'::chat_message_role]))); - -COMMENT ON INDEX idx_chat_messages_search_tsv_pending IS 'Partial index over chat_messages used for populating search_tsv in the background. Only defined over ''searchable'' rows of chat_messages whose search_tsv is NULL or was produced with a stale text search config.'; +CREATE INDEX idx_chat_messages_search_tsv_pending ON chat_messages USING btree (id DESC) WHERE ((search_tsv IS NULL) AND (deleted = false) AND (visibility = ANY (ARRAY['user'::chat_message_visibility, 'both'::chat_message_visibility])) AND (role = ANY (ARRAY['user'::chat_message_role, 'assistant'::chat_message_role]))); CREATE INDEX idx_chat_messages_user_prompts ON chat_messages USING btree (chat_id, id DESC) WHERE ((deleted = false) AND (role = 'user'::chat_message_role) AND (visibility = ANY (ARRAY['user'::chat_message_visibility, 'both'::chat_message_visibility]))); diff --git a/coderd/database/migrations/000585_chat_search_english_config.down.sql b/coderd/database/migrations/000585_chat_search_english_config.down.sql index aff1668caec..f18e57f2155 100644 --- a/coderd/database/migrations/000585_chat_search_english_config.down.sql +++ b/coderd/database/migrations/000585_chat_search_english_config.down.sql @@ -12,21 +12,19 @@ CREATE INDEX idx_chat_diff_statuses_pr_title_fts ON chat_diff_statuses USING GIN COMMENT ON INDEX idx_chat_diff_statuses_pr_title_fts IS 'Used for full text search. Defined over all rows of the chat_diff_statuses table.'; --- Dropped before the reset below so the UPDATE does not maintain an --- index that is about to be recreated with the old predicate anyway. -DROP INDEX idx_chat_messages_search_tsv_pending; - -- Hand rows re-vectorized with 'english' back to the parent version's -- bounded dbpurge sweep. The parent backfill only selects rows with -- search_tsv IS NULL, so 'english' vectors must be reset to NULL to --- re-enter its pending queue; the parent sweep then rewrites them with --- 'simple' incrementally, newest first. Rows never re-vectorized --- (search_tsv_config IS NULL) still hold valid 'simple' lexemes and --- are left untouched. The reset size is proportional to how much of --- the backlog the upgraded sweep processed before the rollback, so a --- prompt rollback rewrites a small set; only a rollback after a full --- drain approaches a full-table update, which is unavoidable because --- the parent job has no other way to find these rows. +-- re-enter its pending queue (idx_chat_messages_search_tsv_pending, +-- which this migration never modified); the parent sweep then rewrites +-- them with 'simple' incrementally, newest first. Rows never +-- re-vectorized (search_tsv_config IS NULL) still hold valid 'simple' +-- lexemes and are left untouched. The reset size is proportional to +-- how much of the backlog the upgraded sweep processed before the +-- rollback, so a prompt rollback rewrites a small set; only a rollback +-- after a full drain approaches a full-table update, which is +-- unavoidable because the parent job has no other way to find these +-- rows. -- -- This UPDATE runs while the replacement trigger functions (which -- exclude search_tsv and search_tsv_config from change comparisons) @@ -37,11 +35,6 @@ UPDATE chat_messages SET search_tsv = NULL WHERE search_tsv_config = 'english'; ALTER TABLE chat_messages DROP COLUMN search_tsv_config; -CREATE INDEX idx_chat_messages_search_tsv_pending ON chat_messages USING btree (id DESC) -WHERE ((search_tsv IS NULL) AND (deleted = false) AND (visibility = ANY (ARRAY['user'::chat_message_visibility, 'both'::chat_message_visibility])) AND (role = ANY (ARRAY['user'::chat_message_role, 'assistant'::chat_message_role]))); - -COMMENT ON INDEX idx_chat_messages_search_tsv_pending IS 'Partial index over chat_messages used for populating search_tsv in the background. Only defined over ''searchable'' rows of chat_messages where search_tsv is NULL.'; - -- Restore the original trigger function bodies from 000545. CREATE OR REPLACE FUNCTION set_chat_message_revision_before() diff --git a/coderd/database/migrations/000585_chat_search_english_config.up.sql b/coderd/database/migrations/000585_chat_search_english_config.up.sql index cc3c30ce50f..40303db5c01 100644 --- a/coderd/database/migrations/000585_chat_search_english_config.up.sql +++ b/coderd/database/migrations/000585_chat_search_english_config.up.sql @@ -5,14 +5,13 @@ -- -- The title expression indexes are rebuilt here; chats and -- chat_diff_statuses hold one row per chat, so the rebuild is cheap. --- The stored chat_messages.search_tsv vectors are NOT rewritten here: --- chat_messages can be large, and a full-table UPDATE inside the --- migration transaction would rewrite every row, churn both partial --- indexes, and hold the locks and WAL until the whole migration batch --- commits. Instead the new search_tsv_config column records which --- config produced each stored vector, the pending-queue index treats --- any row whose config is not 'english' as pending, and the bounded --- dbpurge sweep rewrites stale rows incrementally, newest first. +-- chat_messages gets no index or data changes at all: a full-table +-- UPDATE of search_tsv or a non-concurrent index rebuild would block +-- message writes for the duration on large tables. Instead the new +-- search_tsv_config column records which config produced each stored +-- vector, and the bounded dbpurge sweep finds rows whose config is not +-- 'english' with a self-terminating scan and rewrites them +-- incrementally, newest first (see ReindexStaleChatMessagesSearchTsv). DROP INDEX idx_chats_title_fts; @@ -30,19 +29,6 @@ ALTER TABLE chat_messages ADD COLUMN search_tsv_config text; COMMENT ON COLUMN chat_messages.search_tsv_config IS 'Text search config that produced search_tsv. NULL means the vector is stale (produced by an unknown config) and the row is pending re-vectorization. Binaries that predate this column cannot set it, so vectors written by an old replica during a rolling upgrade stay pending and are rewritten by an upgraded replica''s sweep.'; --- A row is pending when it has never been vectorized (search_tsv IS --- NULL) or when its stored vector was produced with a different config --- (search_tsv_config IS DISTINCT FROM 'english'). All pre-existing --- rows have search_tsv_config NULL, so the entire eligible backlog --- re-enters this queue with no data movement; the index shrinks as the --- sweep drains it. -DROP INDEX idx_chat_messages_search_tsv_pending; - -CREATE INDEX idx_chat_messages_search_tsv_pending ON chat_messages USING btree (id DESC) -WHERE (((search_tsv IS NULL) OR (search_tsv_config IS DISTINCT FROM 'english')) AND (deleted = false) AND (visibility = ANY (ARRAY['user'::chat_message_visibility, 'both'::chat_message_visibility])) AND (role = ANY (ARRAY['user'::chat_message_role, 'assistant'::chat_message_role]))); - -COMMENT ON INDEX idx_chat_messages_search_tsv_pending IS 'Partial index over chat_messages used for populating search_tsv in the background. Only defined over ''searchable'' rows of chat_messages whose search_tsv is NULL or was produced with a stale text search config.'; - -- The sweep now also writes search_tsv_config, so both chatd trigger -- functions must exclude it (alongside search_tsv) when deciding -- whether a message change is meaningful. Without this every backfill diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index c0fce1bc403..42531844b72 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -2392,11 +2392,6 @@ const eligibilityPredicate = `deleted = false AND visibility IN ('user', 'both') AND role IN ('user', 'assistant')` -// Pending-queue membership: never vectorized, or vectorized with a -// stale text search config. Must match the predicate of -// idx_chat_messages_search_tsv_pending. -const pendingPredicate = `(search_tsv IS NULL OR search_tsv_config IS DISTINCT FROM 'english')` - func TestMigration000543ChatSearchSchemaIndexes(t *testing.T) { t.Parallel() if testing.Short() { @@ -2480,7 +2475,7 @@ func TestMigration000543ChatSearchSchemaBehavior(t *testing.T) { pendingIDs := func(ctx context.Context, limit int) []int64 { rows, err := sqlDB.QueryContext(ctx, ` SELECT id FROM chat_messages - WHERE `+pendingPredicate+` AND `+eligibilityPredicate+` + WHERE search_tsv IS NULL AND `+eligibilityPredicate+` ORDER BY id DESC LIMIT $1`, limit) require.NoError(t, err) @@ -2518,8 +2513,7 @@ func TestMigration000543ChatSearchSchemaBehavior(t *testing.T) { require.Equal(t, []int64{eligibleNoText.ID, eligibleText.ID}, pendingIDs(ctx, 10)) // Sweep-style UPDATE. The '' sentinel (not NULL) marks no-text rows as - // swept; search_tsv_config records the config and is what drains rows - // from the queue. + // swept; search_tsv_config records the config that produced the vector. _, err = sqlDB.ExecContext(ctx, ` UPDATE chat_messages SET search_tsv = COALESCE(to_tsvector('english', chat_message_search_text(content)), ''::tsvector), @@ -2528,24 +2522,45 @@ func TestMigration000543ChatSearchSchemaBehavior(t *testing.T) { require.NoError(t, err) require.Empty(t, pendingIDs(ctx, 10), "swept rows must leave the queue, including no-text rows") - // A non-NULL vector with a stale config stays pending: this is how - // rows written by an old binary during a rolling upgrade (which - // cannot set search_tsv_config) re-enter the queue and self-heal. + // A non-NULL vector with a stale config is not in the NULL queue but + // must be found by the stale-reindex predicate: this is how rows + // written by an old binary during a rolling upgrade (which cannot set + // search_tsv_config) are eventually rewritten. + staleIDs := func(ctx context.Context, limit int) []int64 { + rows, err := sqlDB.QueryContext(ctx, ` + SELECT id FROM chat_messages + WHERE search_tsv IS NOT NULL + AND search_tsv_config IS DISTINCT FROM 'english' + AND `+eligibilityPredicate+` + ORDER BY id DESC + LIMIT $1`, limit) + require.NoError(t, err) + defer rows.Close() + var ids []int64 + for rows.Next() { + var id int64 + require.NoError(t, rows.Scan(&id)) + ids = append(ids, id) + } + require.NoError(t, rows.Err()) + return ids + } _, err = sqlDB.ExecContext(ctx, ` UPDATE chat_messages SET search_tsv = to_tsvector('simple', chat_message_search_text(content)), search_tsv_config = NULL WHERE id = $1`, eligibleText.ID) require.NoError(t, err) - require.Equal(t, []int64{eligibleText.ID}, pendingIDs(ctx, 10), - "stale-config vectors must re-enter the pending queue") + require.Empty(t, pendingIDs(ctx, 10), "stale rows must not enter the NULL queue") + require.Equal(t, []int64{eligibleText.ID}, staleIDs(ctx, 10), + "stale-config vectors must match the stale-reindex predicate") _, err = sqlDB.ExecContext(ctx, ` UPDATE chat_messages SET search_tsv = COALESCE(to_tsvector('english', chat_message_search_text(content)), ''::tsvector), search_tsv_config = 'english' WHERE id = $1`, eligibleText.ID) require.NoError(t, err) - require.Empty(t, pendingIDs(ctx, 10)) + require.Empty(t, staleIDs(ctx, 10)) // Soft-deleting an unswept row removes it from the queue without a sweep. unswept := newMsg(database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, textContent("unswept deploy row")) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index aacfd92450a..0b26813a273 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -69,17 +69,13 @@ type sqlcQuerier interface { // created_at ASC flows through to dbpurge's digest truncation; see // buildDigestData in dbpurge.go for the tradeoff rationale. AutoArchiveInactiveChats(ctx context.Context, arg AutoArchiveInactiveChatsParams) ([]AutoArchiveInactiveChatsRow, error) - // Backfills chat_messages.search_tsv for pending rows, newest first. - // A row is pending when it has never been vectorized (search_tsv IS - // NULL) or when its stored vector was produced with a stale text - // search config (search_tsv_config IS DISTINCT FROM 'english'), e.g. - // rows indexed before the switch to 'english' or rows written by an - // old binary during a rolling upgrade. The WHERE clause must match - // the predicate of idx_chat_messages_search_tsv_pending exactly so - // the partial index serves this query. + // Backfills chat_messages.search_tsv for never-vectorized rows, newest + // first. The WHERE clause must match the predicate of + // idx_chat_messages_search_tsv_pending exactly so the partial index + // serves this query. Rows whose vector exists but was produced with a + // stale config are handled by ReindexStaleChatMessagesSearchTsv. // NULL means "pending", empty tsvector means "backfilled, no text". - // search_tsv_config records the config that produced the vector and - // is what drains the row from the pending queue. + // search_tsv_config records the config that produced the vector. BackfillChatMessagesSearchTsv(ctx context.Context, batchSize int32) (int64, error) BackoffChatDiffStatus(ctx context.Context, arg BackoffChatDiffStatusParams) error // Deletes heartbeat rows for the supplied (chat_id, runner_id) pairs. @@ -1354,6 +1350,20 @@ type sqlcQuerier interface { PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID) (ChatQueuedMessage, error) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error RegisterWorkspaceProxy(ctx context.Context, arg RegisterWorkspaceProxyParams) (WorkspaceProxy, error) + // Rewrites vectors produced with a stale text search config: rows + // indexed with 'simple' before migration 000585 and rows written by an + // old binary during a rolling upgrade (which cannot stamp + // search_tsv_config). This queue is deliberately unindexed. Stale rows + // are a one-time, shrinking backlog, so a permanent partial index (and + // its per-write maintenance) is not worth it, and rebuilding the + // pending index with a wider predicate in the migration would block + // message writes on large tables. ORDER BY id DESC with LIMIT walks + // the primary key backwards and terminates early while stale rows are + // dense, which is the entire drain; only the final near-empty pass + // costs a full walk, and the dbpurge caller stops calling this query + // for the process lifetime once a pass returns fewer rows than the + // batch size (proof the scan reached the end of the table). + ReindexStaleChatMessagesSearchTsv(ctx context.Context, batchSize int32) (int64, error) // The lease is only removed if it is the current lease. ReleaseExternalAuthLinkRefreshLease(ctx context.Context, arg ReleaseExternalAuthLinkRefreshLeaseParams) error RemoveUserFromGroups(ctx context.Context, arg RemoveUserFromGroupsParams) ([]uuid.UUID, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 5f75809e1cb..c60b908b1e6 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -7401,7 +7401,7 @@ func (q *sqlQuerier) AutoArchiveInactiveChats(ctx context.Context, arg AutoArchi const backfillChatMessagesSearchTsv = `-- name: BackfillChatMessagesSearchTsv :execrows WITH batch AS ( SELECT id FROM chat_messages - WHERE (search_tsv IS NULL OR search_tsv_config IS DISTINCT FROM 'english') + WHERE search_tsv IS NULL AND deleted = false AND visibility IN ('user', 'both') AND role IN ('user', 'assistant') @@ -7416,17 +7416,13 @@ SET search_tsv = COALESCE( FROM batch WHERE cm.id = batch.id ` -// Backfills chat_messages.search_tsv for pending rows, newest first. -// A row is pending when it has never been vectorized (search_tsv IS -// NULL) or when its stored vector was produced with a stale text -// search config (search_tsv_config IS DISTINCT FROM 'english'), e.g. -// rows indexed before the switch to 'english' or rows written by an -// old binary during a rolling upgrade. The WHERE clause must match -// the predicate of idx_chat_messages_search_tsv_pending exactly so -// the partial index serves this query. +// Backfills chat_messages.search_tsv for never-vectorized rows, newest +// first. The WHERE clause must match the predicate of +// idx_chat_messages_search_tsv_pending exactly so the partial index +// serves this query. Rows whose vector exists but was produced with a +// stale config are handled by ReindexStaleChatMessagesSearchTsv. // NULL means "pending", empty tsvector means "backfilled, no text". -// search_tsv_config records the config that produced the vector and -// is what drains the row from the pending queue. +// search_tsv_config records the config that produced the vector. func (q *sqlQuerier) BackfillChatMessagesSearchTsv(ctx context.Context, batchSize int32) (int64, error) { result, err := q.db.ExecContext(ctx, backfillChatMessagesSearchTsv, batchSize) if err != nil { @@ -11387,6 +11383,46 @@ func (q *sqlQuerier) PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID) return i, err } +const reindexStaleChatMessagesSearchTsv = `-- name: ReindexStaleChatMessagesSearchTsv :execrows +WITH batch AS ( + SELECT id FROM chat_messages + WHERE search_tsv IS NOT NULL + AND search_tsv_config IS DISTINCT FROM 'english' + AND deleted = false + AND visibility IN ('user', 'both') + AND role IN ('user', 'assistant') + ORDER BY id DESC + LIMIT $1::int +) +UPDATE chat_messages cm +SET search_tsv = COALESCE( + to_tsvector('english', chat_message_search_text(cm.content)), + ''::tsvector), + search_tsv_config = 'english' +FROM batch WHERE cm.id = batch.id +` + +// Rewrites vectors produced with a stale text search config: rows +// indexed with 'simple' before migration 000585 and rows written by an +// old binary during a rolling upgrade (which cannot stamp +// search_tsv_config). This queue is deliberately unindexed. Stale rows +// are a one-time, shrinking backlog, so a permanent partial index (and +// its per-write maintenance) is not worth it, and rebuilding the +// pending index with a wider predicate in the migration would block +// message writes on large tables. ORDER BY id DESC with LIMIT walks +// the primary key backwards and terminates early while stale rows are +// dense, which is the entire drain; only the final near-empty pass +// costs a full walk, and the dbpurge caller stops calling this query +// for the process lifetime once a pass returns fewer rows than the +// batch size (proof the scan reached the end of the table). +func (q *sqlQuerier) ReindexStaleChatMessagesSearchTsv(ctx context.Context, batchSize int32) (int64, error) { + result, err := q.db.ExecContext(ctx, reindexStaleChatMessagesSearchTsv, batchSize) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const reorderChatQueuedMessageToFront = `-- name: ReorderChatQueuedMessageToFront :execrows UPDATE chat_queued_messages AS target SET created_at = ( diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 28a041fbd50..d6718a8e139 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -315,17 +315,14 @@ WHERE id = @id::bigint; -- name: BackfillChatMessagesSearchTsv :execrows --- Backfills chat_messages.search_tsv for pending rows, newest first. --- A row is pending when it has never been vectorized (search_tsv IS --- NULL) or when its stored vector was produced with a stale text --- search config (search_tsv_config IS DISTINCT FROM 'english'), e.g. --- rows indexed before the switch to 'english' or rows written by an --- old binary during a rolling upgrade. The WHERE clause must match --- the predicate of idx_chat_messages_search_tsv_pending exactly so --- the partial index serves this query. +-- Backfills chat_messages.search_tsv for never-vectorized rows, newest +-- first. The WHERE clause must match the predicate of +-- idx_chat_messages_search_tsv_pending exactly so the partial index +-- serves this query. Rows whose vector exists but was produced with a +-- stale config are handled by ReindexStaleChatMessagesSearchTsv. WITH batch AS ( SELECT id FROM chat_messages - WHERE (search_tsv IS NULL OR search_tsv_config IS DISTINCT FROM 'english') + WHERE search_tsv IS NULL AND deleted = false AND visibility IN ('user', 'both') AND role IN ('user', 'assistant') @@ -334,8 +331,38 @@ WITH batch AS ( ) UPDATE chat_messages cm -- NULL means "pending", empty tsvector means "backfilled, no text". --- search_tsv_config records the config that produced the vector and --- is what drains the row from the pending queue. +-- search_tsv_config records the config that produced the vector. +SET search_tsv = COALESCE( + to_tsvector('english', chat_message_search_text(cm.content)), + ''::tsvector), + search_tsv_config = 'english' +FROM batch WHERE cm.id = batch.id; + +-- name: ReindexStaleChatMessagesSearchTsv :execrows +-- Rewrites vectors produced with a stale text search config: rows +-- indexed with 'simple' before migration 000585 and rows written by an +-- old binary during a rolling upgrade (which cannot stamp +-- search_tsv_config). This queue is deliberately unindexed. Stale rows +-- are a one-time, shrinking backlog, so a permanent partial index (and +-- its per-write maintenance) is not worth it, and rebuilding the +-- pending index with a wider predicate in the migration would block +-- message writes on large tables. ORDER BY id DESC with LIMIT walks +-- the primary key backwards and terminates early while stale rows are +-- dense, which is the entire drain; only the final near-empty pass +-- costs a full walk, and the dbpurge caller stops calling this query +-- for the process lifetime once a pass returns fewer rows than the +-- batch size (proof the scan reached the end of the table). +WITH batch AS ( + SELECT id FROM chat_messages + WHERE search_tsv IS NOT NULL + AND search_tsv_config IS DISTINCT FROM 'english' + AND deleted = false + AND visibility IN ('user', 'both') + AND role IN ('user', 'assistant') + ORDER BY id DESC + LIMIT @batch_size::int +) +UPDATE chat_messages cm SET search_tsv = COALESCE( to_tsvector('english', chat_message_search_text(cm.content)), ''::tsvector), From 73a7c82f5457902112adf05566f616103f4d6761 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Mon, 24 Aug 2026 15:55:42 +0000 Subject: [PATCH 08/16] test(coderd/database/dbpurge): expect stale reindex query in mocked metrics tests TestMetrics/FailedChatRetentionRead and FailedChatDebugRetentionRead drive doTick against a MockStore, which fails on any call without an expectation. Register ReindexStaleChatMessagesSearchTsv alongside the existing BackfillChatMessagesSearchTsv expectations, and assert it is never called when the purge lock is held elsewhere. --- coderd/database/dbpurge/dbpurge_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/coderd/database/dbpurge/dbpurge_test.go b/coderd/database/dbpurge/dbpurge_test.go index 36d5482b94d..594e65f093a 100644 --- a/coderd/database/dbpurge/dbpurge_test.go +++ b/coderd/database/dbpurge/dbpurge_test.go @@ -256,6 +256,7 @@ func TestMetrics(t *testing.T) { mDB.EXPECT().DeleteOldWorkspaceBuildOrchestrations(gomock.Any(), gomock.Any()).Return(int64(0), nil).AnyTimes() mDB.EXPECT().DeleteOldAuditLogConnectionEvents(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() mDB.EXPECT().BackfillChatMessagesSearchTsv(gomock.Any(), gomock.Any()).Return(int64(0), nil).AnyTimes() + mDB.EXPECT().ReindexStaleChatMessagesSearchTsv(gomock.Any(), gomock.Any()).Return(int64(0), nil).AnyTimes() mDB.EXPECT().DeleteOldChatDebugRuns(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatDebugRunsParams{})).Return(int64(0), nil).MinTimes(1) mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")). DoAndReturn(func(f func(database.Store) error, _ *database.TxOptions) error { @@ -308,6 +309,7 @@ func TestMetrics(t *testing.T) { mDB.EXPECT().DeleteOldWorkspaceBuildOrchestrations(gomock.Any(), gomock.Any()).Return(int64(0), nil).AnyTimes() mDB.EXPECT().DeleteOldAuditLogConnectionEvents(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() mDB.EXPECT().BackfillChatMessagesSearchTsv(gomock.Any(), gomock.Any()).Return(int64(0), nil).AnyTimes() + mDB.EXPECT().ReindexStaleChatMessagesSearchTsv(gomock.Any(), gomock.Any()).Return(int64(0), nil).AnyTimes() mDB.EXPECT().DeleteOldChats(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatsParams{})).Return(int64(0), nil).MinTimes(1) mDB.EXPECT().DeleteOldChatFiles(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatFilesParams{})).Return(int64(0), nil).MinTimes(1) mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")). @@ -3410,6 +3412,7 @@ func TestBackfillChatMessagesSearchTsv(t *testing.T) { Return(int32(0), nil).AnyTimes() mDB.EXPECT().TryAcquireLock(gomock.Any(), int64(database.LockIDDBPurge)).Return(false, nil).AnyTimes() mDB.EXPECT().BackfillChatMessagesSearchTsv(gomock.Any(), gomock.Any()).Times(0) + mDB.EXPECT().ReindexStaleChatMessagesSearchTsv(gomock.Any(), gomock.Any()).Times(0) mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")). DoAndReturn(func(f func(database.Store) error, _ *database.TxOptions) error { return f(mDB) From a49bea8f29f18cf52a1eefdb2d85b8d3f2f1be4a Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Mon, 24 Aug 2026 16:40:56 +0000 Subject: [PATCH 09/16] fix(coderd): keep title search on ILIKE and scope english config to messages --- coderd/database/dump.sql | 6 +-- ...000585_chat_search_english_config.down.sql | 15 +------ .../000585_chat_search_english_config.up.sql | 39 +++++++------------ coderd/database/querier_test.go | 16 +++++--- coderd/database/queries.sql.go | 21 +++++----- coderd/database/queries/chats.sql | 21 +++++----- coderd/exp_chats_test.go | 13 ++++--- 7 files changed, 58 insertions(+), 73 deletions(-) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 8ad0121bdd4..f426f0857f0 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -4888,9 +4888,7 @@ CREATE UNIQUE INDEX idx_chat_debug_steps_run_step ON chat_debug_steps USING btre CREATE INDEX idx_chat_debug_steps_stale ON chat_debug_steps USING btree (updated_at) WHERE (finished_at IS NULL); -CREATE INDEX idx_chat_diff_statuses_pr_title_fts ON chat_diff_statuses USING gin (to_tsvector('english'::regconfig, pull_request_title)); - -COMMENT ON INDEX idx_chat_diff_statuses_pr_title_fts IS 'Used for full text search. Defined over all rows of the chat_diff_statuses table.'; +CREATE INDEX idx_chat_diff_statuses_pr_title_fts ON chat_diff_statuses USING gin (to_tsvector('simple'::regconfig, pull_request_title)); CREATE INDEX idx_chat_diff_statuses_stale_at ON chat_diff_statuses USING btree (stale_at); @@ -4952,7 +4950,7 @@ CREATE INDEX idx_chats_parent_chat_id ON chats USING btree (parent_chat_id); CREATE INDEX idx_chats_root_chat_id ON chats USING btree (root_chat_id); -CREATE INDEX idx_chats_title_fts ON chats USING gin (to_tsvector('english'::regconfig, title)); +CREATE INDEX idx_chats_title_fts ON chats USING gin (to_tsvector('simple'::regconfig, title)); COMMENT ON INDEX idx_chats_title_fts IS 'Used for full text search. Defined over all rows of the chats table.'; diff --git a/coderd/database/migrations/000585_chat_search_english_config.down.sql b/coderd/database/migrations/000585_chat_search_english_config.down.sql index f18e57f2155..a736b199f64 100644 --- a/coderd/database/migrations/000585_chat_search_english_config.down.sql +++ b/coderd/database/migrations/000585_chat_search_english_config.down.sql @@ -1,16 +1,5 @@ --- Revert chat full-text search to the 'simple' text search config. - -DROP INDEX idx_chats_title_fts; - -CREATE INDEX idx_chats_title_fts ON chats USING GIN (to_tsvector('simple', title)); - -COMMENT ON INDEX idx_chats_title_fts IS 'Used for full text search. Defined over all rows of the chats table.'; - -DROP INDEX idx_chat_diff_statuses_pr_title_fts; - -CREATE INDEX idx_chat_diff_statuses_pr_title_fts ON chat_diff_statuses USING GIN (to_tsvector('simple', pull_request_title)); - -COMMENT ON INDEX idx_chat_diff_statuses_pr_title_fts IS 'Used for full text search. Defined over all rows of the chat_diff_statuses table.'; +-- Revert chat message full-text search to the 'simple' text search +-- config. -- Hand rows re-vectorized with 'english' back to the parent version's -- bounded dbpurge sweep. The parent backfill only selects rows with diff --git a/coderd/database/migrations/000585_chat_search_english_config.up.sql b/coderd/database/migrations/000585_chat_search_english_config.up.sql index 40303db5c01..bc0c6ad0546 100644 --- a/coderd/database/migrations/000585_chat_search_english_config.up.sql +++ b/coderd/database/migrations/000585_chat_search_english_config.up.sql @@ -1,29 +1,18 @@ --- Switch chat full-text search from the 'simple' to the 'english' text --- search config so queries match inflected forms (e.g. searching --- "refactor" matches "refactoring"). The config baked into a tsvector --- must match the config used by the tsquery at search time. +-- Switch chat message full-text search from the 'simple' to the +-- 'english' text search config so queries match inflected forms +-- (e.g. searching "refactor" matches "refactoring"). The config baked +-- into a tsvector must match the config used by the tsquery at search +-- time. -- --- The title expression indexes are rebuilt here; chats and --- chat_diff_statuses hold one row per chat, so the rebuild is cheap. --- chat_messages gets no index or data changes at all: a full-table --- UPDATE of search_tsv or a non-concurrent index rebuild would block --- message writes for the duration on large tables. Instead the new --- search_tsv_config column records which config produced each stored --- vector, and the bounded dbpurge sweep finds rows whose config is not --- 'english' with a self-terminating scan and rewrites them --- incrementally, newest first (see ReindexStaleChatMessagesSearchTsv). - -DROP INDEX idx_chats_title_fts; - -CREATE INDEX idx_chats_title_fts ON chats USING GIN (to_tsvector('english', title)); - -COMMENT ON INDEX idx_chats_title_fts IS 'Used for full text search. Defined over all rows of the chats table.'; - -DROP INDEX idx_chat_diff_statuses_pr_title_fts; - -CREATE INDEX idx_chat_diff_statuses_pr_title_fts ON chat_diff_statuses USING GIN (to_tsvector('english', pull_request_title)); - -COMMENT ON INDEX idx_chat_diff_statuses_pr_title_fts IS 'Used for full text search. Defined over all rows of the chat_diff_statuses table.'; +-- Only chat_messages.search_tsv is affected; title and pull request +-- title matching uses ILIKE and no index changes. chat_messages gets +-- no index or data changes at all: a full-table UPDATE of search_tsv +-- or a non-concurrent index rebuild would block message writes for +-- the duration on large tables. Instead the new search_tsv_config +-- column records which config produced each stored vector, and the +-- bounded dbpurge sweep finds rows whose config is not 'english' with +-- a self-terminating scan and rewrites them incrementally, newest +-- first (see ReindexStaleChatMessagesSearchTsv). ALTER TABLE chat_messages ADD COLUMN search_tsv_config text; diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index b9e2ac2dea1..e708438335c 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -18368,14 +18368,18 @@ func TestGetChatsSearch(t *testing.T) { want []uuid.UUID }{ {"Title/Match", database.GetChatsParams{Search: "pipeline alpha"}, []uuid.UUID{titleChat.ID}}, - {"Title/CaseInsensitiveMultiWord", database.GetChatsParams{Search: "ALPHA DEPLOY"}, []uuid.UUID{titleChat.ID}}, - {"Title/AndSemantics", database.GetChatsParams{Search: "deploy nonexistent"}, nil}, - // The 'english' config stems both sides, so inflected query forms - // match the stored words. - {"Title/StemmedMatch", database.GetChatsParams{Search: "deploying pipelines"}, []uuid.UUID{titleChat.ID, archivedChat.ID}}, + {"Title/CaseInsensitiveSubstring", database.GetChatsParams{Search: "PIPELINE ALPHA"}, []uuid.UUID{titleChat.ID}}, + // Titles match by substring, so reordered words and non-substrings + // do not match. + {"Title/ReorderedWordsNoMatch", database.GetChatsParams{Search: "alpha deploy"}, nil}, + {"Title/NonSubstringNoMatch", database.GetChatsParams{Search: "deploy nonexistent"}, nil}, + // Substring matching covers partial words without any stemming. + {"Title/PartialWordMatch", database.GetChatsParams{Search: "pipel"}, []uuid.UUID{titleChat.ID, archivedChat.ID}}, {"PRTitle/Match", database.GetChatsParams{Search: "authentication"}, []uuid.UUID{prTitleChat.ID, mergedChat.ID}}, - {"PRTitle/StemmedMatch", database.GetChatsParams{Search: "authenticating"}, []uuid.UUID{prTitleChat.ID, mergedChat.ID}}, + {"PRTitle/PartialWordMatch", database.GetChatsParams{Search: "authenticat"}, []uuid.UUID{prTitleChat.ID, mergedChat.ID}}, {"Message/Match", database.GetChatsParams{Search: "kubernetes restart"}, []uuid.UUID{msgChat.ID}}, + // The 'english' config stems both sides, so inflected query forms + // match the stored words. {"Message/StemmedMatch", database.GetChatsParams{Search: "restarting clusters"}, []uuid.UUID{msgChat.ID}}, // Stale 'simple' vectors are queried with their own config: the // exact form matches like before the migration, the stemmed form diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index c60b908b1e6..891d7ced667 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -9573,22 +9573,23 @@ WHERE ) ELSE true END - -- websearch_to_tsquery accepts quoted phrases, OR, and -negation; - -- the 'english' config folds case and stems words, so inflected - -- forms match (e.g. "refactor" matches "refactoring"). The config - -- must match the one baked into the tsvectors and expression - -- indexes (idx_chats_title_fts, idx_chat_diff_statuses_pr_title_fts, - -- and the backfilled chat_messages.search_tsv). + -- Titles and pull request titles use case-insensitive substring + -- matching, same as the dedicated title:/pr_title: filters, so + -- partial words match (e.g. "refactor" matches "Refactoring the + -- parser"). Message bodies use full text search: + -- websearch_to_tsquery accepts quoted phrases, OR, and -negation, + -- and the 'english' config folds case and stems words so inflected + -- forms match (e.g. "refactor" matches "refactoring"). The tsquery + -- config must match the one baked into each stored + -- chat_messages.search_tsv vector. AND CASE WHEN $16::text != '' THEN ( - -- Served by idx_chats_title_fts. - to_tsvector('english', chats_expanded.title) @@ websearch_to_tsquery('english', $16) - -- Served by idx_chat_diff_statuses_pr_title_fts. + chats_expanded.title ILIKE '%' || $16 || '%' OR EXISTS ( SELECT 1 FROM chat_diff_statuses cds WHERE cds.chat_id = chats_expanded.id - AND to_tsvector('english', cds.pull_request_title) @@ websearch_to_tsquery('english', $16) + AND cds.pull_request_title ILIKE '%' || $16 || '%' ) -- The WHERE clause must repeat the predicate of the partial index -- idx_chat_messages_search_tsv so the planner can use it. Additional diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index d6718a8e139..95dfcb5b167 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -712,22 +712,23 @@ WHERE ) ELSE true END - -- websearch_to_tsquery accepts quoted phrases, OR, and -negation; - -- the 'english' config folds case and stems words, so inflected - -- forms match (e.g. "refactor" matches "refactoring"). The config - -- must match the one baked into the tsvectors and expression - -- indexes (idx_chats_title_fts, idx_chat_diff_statuses_pr_title_fts, - -- and the backfilled chat_messages.search_tsv). + -- Titles and pull request titles use case-insensitive substring + -- matching, same as the dedicated title:/pr_title: filters, so + -- partial words match (e.g. "refactor" matches "Refactoring the + -- parser"). Message bodies use full text search: + -- websearch_to_tsquery accepts quoted phrases, OR, and -negation, + -- and the 'english' config folds case and stems words so inflected + -- forms match (e.g. "refactor" matches "refactoring"). The tsquery + -- config must match the one baked into each stored + -- chat_messages.search_tsv vector. AND CASE WHEN @search::text != '' THEN ( - -- Served by idx_chats_title_fts. - to_tsvector('english', chats_expanded.title) @@ websearch_to_tsquery('english', @search) - -- Served by idx_chat_diff_statuses_pr_title_fts. + chats_expanded.title ILIKE '%' || @search || '%' OR EXISTS ( SELECT 1 FROM chat_diff_statuses cds WHERE cds.chat_id = chats_expanded.id - AND to_tsvector('english', cds.pull_request_title) @@ websearch_to_tsquery('english', @search) + AND cds.pull_request_title ILIKE '%' || @search || '%' ) -- The WHERE clause must repeat the predicate of the partial index -- idx_chat_messages_search_tsv so the planner can use it. Additional diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 1f0acd20b6c..6187dc66c2a 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -3029,11 +3029,14 @@ func TestListChats_Search(t *testing.T) { t.Parallel() ctx, client, db, firstUser, modelConfig := setup(t) - // The 'english' config drops stopwords, so search:"or" tokenizes - // to no lexemes and matches nothing, just like search:"!!!". The - // control query proves the empty results come from tokenization, - // not missing data. - control := createChat(t, db, firstUser, modelConfig.ID, "fix this or that") + // Message search uses the 'english' config, which drops + // stopwords: search:"or" tokenizes to no lexemes and matches + // nothing, just like search:"!!!". The title must not contain the + // searched substrings because titles match by ILIKE, not + // tokenization. The control query proves the empty results come + // from tokenization, not missing data. + control := createChat(t, db, firstUser, modelConfig.ID, "plain title") + insertMessage(t, db, firstUser, modelConfig.ID, control.ID, "fix this or that") backfillSearchTsv(ctx, t, db) chats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{ From 36c522d149f8a0a59e7f051bbd4ad11c0e8892f5 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Mon, 24 Aug 2026 17:07:37 +0000 Subject: [PATCH 10/16] fix(coderd): scope english search config to message bodies only --- .../000585_chat_search_english_config.up.sql | 16 +++++++------- coderd/database/querier_test.go | 14 ++++++------- coderd/database/queries.sql.go | 21 +++++++++---------- coderd/database/queries/chats.sql | 21 +++++++++---------- coderd/exp_chats_test.go | 6 +++--- 5 files changed, 37 insertions(+), 41 deletions(-) diff --git a/coderd/database/migrations/000585_chat_search_english_config.up.sql b/coderd/database/migrations/000585_chat_search_english_config.up.sql index bc0c6ad0546..99f85a9437e 100644 --- a/coderd/database/migrations/000585_chat_search_english_config.up.sql +++ b/coderd/database/migrations/000585_chat_search_english_config.up.sql @@ -5,14 +5,14 @@ -- time. -- -- Only chat_messages.search_tsv is affected; title and pull request --- title matching uses ILIKE and no index changes. chat_messages gets --- no index or data changes at all: a full-table UPDATE of search_tsv --- or a non-concurrent index rebuild would block message writes for --- the duration on large tables. Instead the new search_tsv_config --- column records which config produced each stored vector, and the --- bounded dbpurge sweep finds rows whose config is not 'english' with --- a self-terminating scan and rewrites them incrementally, newest --- first (see ReindexStaleChatMessagesSearchTsv). +-- title matching keeps its existing 'simple' behavior and indexes. +-- chat_messages gets no index or data changes at all: a full-table +-- UPDATE of search_tsv or a non-concurrent index rebuild would block +-- message writes for the duration on large tables. Instead the new +-- search_tsv_config column records which config produced each stored +-- vector, and the bounded dbpurge sweep finds rows whose config is +-- not 'english' with a self-terminating scan and rewrites them +-- incrementally, newest first (see ReindexStaleChatMessagesSearchTsv). ALTER TABLE chat_messages ADD COLUMN search_tsv_config text; diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index e708438335c..26692b6f3fa 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -18368,15 +18368,13 @@ func TestGetChatsSearch(t *testing.T) { want []uuid.UUID }{ {"Title/Match", database.GetChatsParams{Search: "pipeline alpha"}, []uuid.UUID{titleChat.ID}}, - {"Title/CaseInsensitiveSubstring", database.GetChatsParams{Search: "PIPELINE ALPHA"}, []uuid.UUID{titleChat.ID}}, - // Titles match by substring, so reordered words and non-substrings - // do not match. - {"Title/ReorderedWordsNoMatch", database.GetChatsParams{Search: "alpha deploy"}, nil}, - {"Title/NonSubstringNoMatch", database.GetChatsParams{Search: "deploy nonexistent"}, nil}, - // Substring matching covers partial words without any stemming. - {"Title/PartialWordMatch", database.GetChatsParams{Search: "pipel"}, []uuid.UUID{titleChat.ID, archivedChat.ID}}, + {"Title/CaseInsensitiveMultiWord", database.GetChatsParams{Search: "ALPHA DEPLOY"}, []uuid.UUID{titleChat.ID}}, + {"Title/AndSemantics", database.GetChatsParams{Search: "deploy nonexistent"}, nil}, + // Titles keep the 'simple' config, which does not stem, so + // inflected query forms only match message bodies. + {"Title/NoStemming", database.GetChatsParams{Search: "deploying pipelines"}, nil}, {"PRTitle/Match", database.GetChatsParams{Search: "authentication"}, []uuid.UUID{prTitleChat.ID, mergedChat.ID}}, - {"PRTitle/PartialWordMatch", database.GetChatsParams{Search: "authenticat"}, []uuid.UUID{prTitleChat.ID, mergedChat.ID}}, + {"PRTitle/NoStemming", database.GetChatsParams{Search: "authenticating"}, nil}, {"Message/Match", database.GetChatsParams{Search: "kubernetes restart"}, []uuid.UUID{msgChat.ID}}, // The 'english' config stems both sides, so inflected query forms // match the stored words. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 891d7ced667..94467675778 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -9573,23 +9573,22 @@ WHERE ) ELSE true END - -- Titles and pull request titles use case-insensitive substring - -- matching, same as the dedicated title:/pr_title: filters, so - -- partial words match (e.g. "refactor" matches "Refactoring the - -- parser"). Message bodies use full text search: - -- websearch_to_tsquery accepts quoted phrases, OR, and -negation, - -- and the 'english' config folds case and stems words so inflected - -- forms match (e.g. "refactor" matches "refactoring"). The tsquery - -- config must match the one baked into each stored - -- chat_messages.search_tsv vector. + -- websearch_to_tsquery accepts quoted phrases, OR, and -negation; + -- the 'simple' config folds case and skips stemming. Message + -- bodies use the 'english' config instead, which also stems words + -- so inflected forms match (e.g. "refactor" matches + -- "refactoring"); the tsquery config must match the one baked into + -- each stored chat_messages.search_tsv vector. AND CASE WHEN $16::text != '' THEN ( - chats_expanded.title ILIKE '%' || $16 || '%' + -- Served by idx_chats_title_fts. + to_tsvector('simple', chats_expanded.title) @@ websearch_to_tsquery('simple', $16) + -- Served by idx_chat_diff_statuses_pr_title_fts. OR EXISTS ( SELECT 1 FROM chat_diff_statuses cds WHERE cds.chat_id = chats_expanded.id - AND cds.pull_request_title ILIKE '%' || $16 || '%' + AND to_tsvector('simple', cds.pull_request_title) @@ websearch_to_tsquery('simple', $16) ) -- The WHERE clause must repeat the predicate of the partial index -- idx_chat_messages_search_tsv so the planner can use it. Additional diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 95dfcb5b167..c7d19b3e770 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -712,23 +712,22 @@ WHERE ) ELSE true END - -- Titles and pull request titles use case-insensitive substring - -- matching, same as the dedicated title:/pr_title: filters, so - -- partial words match (e.g. "refactor" matches "Refactoring the - -- parser"). Message bodies use full text search: - -- websearch_to_tsquery accepts quoted phrases, OR, and -negation, - -- and the 'english' config folds case and stems words so inflected - -- forms match (e.g. "refactor" matches "refactoring"). The tsquery - -- config must match the one baked into each stored - -- chat_messages.search_tsv vector. + -- websearch_to_tsquery accepts quoted phrases, OR, and -negation; + -- the 'simple' config folds case and skips stemming. Message + -- bodies use the 'english' config instead, which also stems words + -- so inflected forms match (e.g. "refactor" matches + -- "refactoring"); the tsquery config must match the one baked into + -- each stored chat_messages.search_tsv vector. AND CASE WHEN @search::text != '' THEN ( - chats_expanded.title ILIKE '%' || @search || '%' + -- Served by idx_chats_title_fts. + to_tsvector('simple', chats_expanded.title) @@ websearch_to_tsquery('simple', @search) + -- Served by idx_chat_diff_statuses_pr_title_fts. OR EXISTS ( SELECT 1 FROM chat_diff_statuses cds WHERE cds.chat_id = chats_expanded.id - AND cds.pull_request_title ILIKE '%' || @search || '%' + AND to_tsvector('simple', cds.pull_request_title) @@ websearch_to_tsquery('simple', @search) ) -- The WHERE clause must repeat the predicate of the partial index -- idx_chat_messages_search_tsv so the planner can use it. Additional diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 6187dc66c2a..db861d7b37b 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -3032,9 +3032,9 @@ func TestListChats_Search(t *testing.T) { // Message search uses the 'english' config, which drops // stopwords: search:"or" tokenizes to no lexemes and matches // nothing, just like search:"!!!". The title must not contain the - // searched substrings because titles match by ILIKE, not - // tokenization. The control query proves the empty results come - // from tokenization, not missing data. + // searched words because title search keeps the 'simple' config, + // which retains stopwords. The control query proves the empty + // results come from tokenization, not missing data. control := createChat(t, db, firstUser, modelConfig.ID, "plain title") insertMessage(t, db, firstUser, modelConfig.ID, control.ID, "fix this or that") backfillSearchTsv(ctx, t, db) From 515e6a0623f184e25c3e1030e5946a90a764329d Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Mon, 24 Aug 2026 17:19:23 +0000 Subject: [PATCH 11/16] docs(coderd): scope stemming claim in chat search annotation to message bodies --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- coderd/exp_chats.go | 2 +- docs/reference/api/chats.md | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 70d728d4ee1..cb77c8fb204 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -168,7 +168,7 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "Search query. Supports ` + "`" + `title:\u003csubstring\u003e` + "`" + ` (case-insensitive, quote multi-word values), ` + "`" + `archived:bool` + "`" + `, ` + "`" + `has_unread:bool` + "`" + `, ` + "`" + `pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e` + "`" + ` as repeated or comma-separated values, ` + "`" + `source:\u003ccreated_by_me\\|shared_with_me\u003e` + "`" + `, ` + "`" + `diff_url:\u003curl\u003e` + "`" + ` (quote values containing colons), ` + "`" + `pr:\u003cnumber\u003e` + "`" + ` (exact PR number match), ` + "`" + `repo:\u003cowner/repo\u003e` + "`" + ` (case-insensitive substring match against git remote origin or URL), ` + "`" + `pr_title:\u003ctext\u003e` + "`" + ` (case-insensitive PR title substring), ` + "`" + `search:\u003ctext\u003e` + "`" + ` (full-text search across chat titles, PR titles, PR numbers, and message bodies; matches English word stems, e.g. ` + "`" + `refactor` + "`" + ` matches ` + "`" + `refactoring` + "`" + `; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words, including stopword-only values, returns an empty list). Bare terms are not supported; use ` + "`" + `title:\u003cvalue\u003e` + "`" + ` or ` + "`" + `search:\u003cvalue\u003e` + "`" + `.", + "description": "Search query. Supports ` + "`" + `title:\u003csubstring\u003e` + "`" + ` (case-insensitive, quote multi-word values), ` + "`" + `archived:bool` + "`" + `, ` + "`" + `has_unread:bool` + "`" + `, ` + "`" + `pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e` + "`" + ` as repeated or comma-separated values, ` + "`" + `source:\u003ccreated_by_me\\|shared_with_me\u003e` + "`" + `, ` + "`" + `diff_url:\u003curl\u003e` + "`" + ` (quote values containing colons), ` + "`" + `pr:\u003cnumber\u003e` + "`" + ` (exact PR number match), ` + "`" + `repo:\u003cowner/repo\u003e` + "`" + ` (case-insensitive substring match against git remote origin or URL), ` + "`" + `pr_title:\u003ctext\u003e` + "`" + ` (case-insensitive PR title substring), ` + "`" + `search:\u003ctext\u003e` + "`" + ` (full-text search across chat titles, PR titles, PR numbers, and message bodies; message bodies match English word stems, e.g. ` + "`" + `refactor` + "`" + ` matches ` + "`" + `refactoring` + "`" + `, and ignore English stopwords; titles and PR titles match whole words case-insensitively without stemming; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words, e.g. punctuation only, returns an empty list). Bare terms are not supported; use ` + "`" + `title:\u003cvalue\u003e` + "`" + ` or ` + "`" + `search:\u003cvalue\u003e` + "`" + `.", "name": "q", "in": "query" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 038cb6fd352..15b3e113c53 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -137,7 +137,7 @@ "parameters": [ { "type": "string", - "description": "Search query. Supports `title:\u003csubstring\u003e` (case-insensitive, quote multi-word values), `archived:bool`, `has_unread:bool`, `pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e` as repeated or comma-separated values, `source:\u003ccreated_by_me\\|shared_with_me\u003e`, `diff_url:\u003curl\u003e` (quote values containing colons), `pr:\u003cnumber\u003e` (exact PR number match), `repo:\u003cowner/repo\u003e` (case-insensitive substring match against git remote origin or URL), `pr_title:\u003ctext\u003e` (case-insensitive PR title substring), `search:\u003ctext\u003e` (full-text search across chat titles, PR titles, PR numbers, and message bodies; matches English word stems, e.g. `refactor` matches `refactoring`; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words, including stopword-only values, returns an empty list). Bare terms are not supported; use `title:\u003cvalue\u003e` or `search:\u003cvalue\u003e`.", + "description": "Search query. Supports `title:\u003csubstring\u003e` (case-insensitive, quote multi-word values), `archived:bool`, `has_unread:bool`, `pr_status:\u003cdraft\\|open\\|merged\\|closed\u003e` as repeated or comma-separated values, `source:\u003ccreated_by_me\\|shared_with_me\u003e`, `diff_url:\u003curl\u003e` (quote values containing colons), `pr:\u003cnumber\u003e` (exact PR number match), `repo:\u003cowner/repo\u003e` (case-insensitive substring match against git remote origin or URL), `pr_title:\u003ctext\u003e` (case-insensitive PR title substring), `search:\u003ctext\u003e` (full-text search across chat titles, PR titles, PR numbers, and message bodies; message bodies match English word stems, e.g. `refactor` matches `refactoring`, and ignore English stopwords; titles and PR titles match whole words case-insensitively without stemming; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words, e.g. punctuation only, returns an empty list). Bare terms are not supported; use `title:\u003cvalue\u003e` or `search:\u003cvalue\u003e`.", "name": "q", "in": "query" }, diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 8de6620f24b..1088efa622d 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -363,7 +363,7 @@ func (api *API) chatsByWorkspace(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Tags Chats // @Produce json -// @Param q query string false "Search query. Supports `title:` (case-insensitive, quote multi-word values), `archived:bool`, `has_unread:bool`, `pr_status:` as repeated or comma-separated values, `source:`, `diff_url:` (quote values containing colons), `pr:` (exact PR number match), `repo:` (case-insensitive substring match against git remote origin or URL), `pr_title:` (case-insensitive PR title substring), `search:` (full-text search across chat titles, PR titles, PR numbers, and message bodies; matches English word stems, e.g. `refactor` matches `refactoring`; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words, including stopword-only values, returns an empty list). Bare terms are not supported; use `title:` or `search:`." +// @Param q query string false "Search query. Supports `title:` (case-insensitive, quote multi-word values), `archived:bool`, `has_unread:bool`, `pr_status:` as repeated or comma-separated values, `source:`, `diff_url:` (quote values containing colons), `pr:` (exact PR number match), `repo:` (case-insensitive substring match against git remote origin or URL), `pr_title:` (case-insensitive PR title substring), `search:` (full-text search across chat titles, PR titles, PR numbers, and message bodies; message bodies match English word stems, e.g. `refactor` matches `refactoring`, and ignore English stopwords; titles and PR titles match whole words case-insensitively without stemming; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words, e.g. punctuation only, returns an empty list). Bare terms are not supported; use `title:` or `search:`." // @Param label query string false "Filter by label as key:value. Repeat for multiple (AND logic)." // @Success 200 {array} codersdk.Chat // @Router /api/experimental/chats [get] diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 6fa489d7c39..f6a72708fa7 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -27,10 +27,10 @@ Experimental: this endpoint is subject to change. ### Parameters -| Name | In | Type | Required | Description | -|---------|-------|--------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `q` | query | string | false | Search query. Supports `title:` (case-insensitive, quote multi-word values), `archived:bool`, `has_unread:bool`, `pr_status:` as repeated or comma-separated values, `source:`, `diff_url:` (quote values containing colons), `pr:` (exact PR number match), `repo:` (case-insensitive substring match against git remote origin or URL), `pr_title:` (case-insensitive PR title substring), `search:` (full-text search across chat titles, PR titles, PR numbers, and message bodies; matches English word stems, e.g. `refactor` matches `refactoring`; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words, including stopword-only values, returns an empty list). Bare terms are not supported; use `title:` or `search:`. | -| `label` | query | string | false | Filter by label as key:value. Repeat for multiple (AND logic). | +| Name | In | Type | Required | Description | +|---------|-------|--------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `q` | query | string | false | Search query. Supports `title:` (case-insensitive, quote multi-word values), `archived:bool`, `has_unread:bool`, `pr_status:` as repeated or comma-separated values, `source:`, `diff_url:` (quote values containing colons), `pr:` (exact PR number match), `repo:` (case-insensitive substring match against git remote origin or URL), `pr_title:` (case-insensitive PR title substring), `search:` (full-text search across chat titles, PR titles, PR numbers, and message bodies; message bodies match English word stems, e.g. `refactor` matches `refactoring`, and ignore English stopwords; titles and PR titles match whole words case-insensitively without stemming; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words, e.g. punctuation only, returns an empty list). Bare terms are not supported; use `title:` or `search:`. | +| `label` | query | string | false | Filter by label as key:value. Repeat for multiple (AND logic). | ### Example responses From 1f8498796d061e47f8bd15e840dc929e2e539e91 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Tue, 25 Aug 2026 11:01:15 +0000 Subject: [PATCH 12/16] refactor(coderd): trim chat search comments per review --- coderd/database/dbpurge/dbpurge.go | 26 ++++----------- coderd/database/dump.sql | 2 +- ...000585_chat_search_english_config.down.sql | 26 ++++----------- .../000585_chat_search_english_config.up.sql | 29 ++++++----------- coderd/database/models.go | 2 +- coderd/database/querier.go | 18 +++-------- coderd/database/queries.sql.go | 32 +++++-------------- coderd/database/queries/chats.sql | 32 +++++-------------- coderd/x/chatd/ARCHITECTURE.md | 2 -- 9 files changed, 47 insertions(+), 122 deletions(-) diff --git a/coderd/database/dbpurge/dbpurge.go b/coderd/database/dbpurge/dbpurge.go index 47518461899..00cc88a6be9 100644 --- a/coderd/database/dbpurge/dbpurge.go +++ b/coderd/database/dbpurge/dbpurge.go @@ -358,22 +358,12 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time. } } - // Rewrite vectors produced with a stale text search config (rows - // indexed before the switch to 'english', plus rows written by an - // old binary during a rolling upgrade). This backlog is finite: - // upgraded binaries always stamp search_tsv_config, so once the - // scan comes up short the queue can never refill and the query is - // skipped for the process lifetime. A pass returning fewer rows - // than the batch size proves the unindexed scan reached the end of - // the table, so latching on it avoids one extra full walk. If an - // old replica writes a stale row after the latch is set, the row - // stays correctly searchable via its recorded config (GetChats - // matches each vector with the config that produced it) and is - // rewritten after the next process restart re-runs one pass. - // - // staleDrained is committed to the instance only after the - // transaction succeeds; a rollback discards the batch updates, so - // latching inside the transaction could strand stale rows. + // Rewrite vectors produced with a stale text search config + // ('simple' rows from before migration 000585, or rows written by + // an old binary mid rolling upgrade). Upgraded binaries always + // stamp search_tsv_config, so this backlog is finite: once a pass + // returns fewer rows than the batch size the scan has reached the + // end of the table and is skipped for the process lifetime. var reindexedChatSearchRows int64 if !i.chatSearchStaleDrained { for range i.chatSearchBackfillMaxBatches { @@ -459,9 +449,7 @@ type instance struct { chatSearchRowsBackfilled prometheus.Counter chatSearchBackfillBatchSize int32 chatSearchBackfillMaxBatches int - // chatSearchStaleDrained latches off the stale-config reindex scan - // once it observes an empty (or final partial) pass. Only doTick's - // single goroutine touches it. + // Skips the stale-config reindex scan once its backlog is drained. chatSearchStaleDrained bool } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index f426f0857f0..4ae09927522 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -2062,7 +2062,7 @@ COMMENT ON COLUMN chat_messages.reasoning_effort IS 'Stores the selected effort COMMENT ON COLUMN chat_messages.search_tsv IS 'Used for full text search. NULL initially, populated async via background job.'; -COMMENT ON COLUMN chat_messages.search_tsv_config IS 'Text search config that produced search_tsv. NULL means the vector is stale (produced by an unknown config) and the row is pending re-vectorization. Binaries that predate this column cannot set it, so vectors written by an old replica during a rolling upgrade stay pending and are rewritten by an upgraded replica''s sweep.'; +COMMENT ON COLUMN chat_messages.search_tsv_config IS 'Text search config that produced search_tsv. NULL means an unknown config (a pre-migration vector or one written by an old binary); the dbpurge sweep re-vectorizes such rows.'; CREATE SEQUENCE chat_messages_id_seq START WITH 1 diff --git a/coderd/database/migrations/000585_chat_search_english_config.down.sql b/coderd/database/migrations/000585_chat_search_english_config.down.sql index a736b199f64..7e9c4dc10fa 100644 --- a/coderd/database/migrations/000585_chat_search_english_config.down.sql +++ b/coderd/database/migrations/000585_chat_search_english_config.down.sql @@ -1,25 +1,13 @@ -- Revert chat message full-text search to the 'simple' text search -- config. --- Hand rows re-vectorized with 'english' back to the parent version's --- bounded dbpurge sweep. The parent backfill only selects rows with --- search_tsv IS NULL, so 'english' vectors must be reset to NULL to --- re-enter its pending queue (idx_chat_messages_search_tsv_pending, --- which this migration never modified); the parent sweep then rewrites --- them with 'simple' incrementally, newest first. Rows never --- re-vectorized (search_tsv_config IS NULL) still hold valid 'simple' --- lexemes and are left untouched. The reset size is proportional to --- how much of the backlog the upgraded sweep processed before the --- rollback, so a prompt rollback rewrites a small set; only a rollback --- after a full drain approaches a full-table update, which is --- unavoidable because the parent job has no other way to find these --- rows. --- --- This UPDATE runs while the replacement trigger functions (which --- exclude search_tsv and search_tsv_config from change comparisons) --- are still installed, so it does not advance message revisions or --- chat history_version. The original trigger functions are restored --- below, after this statement. +-- Reset 'english' vectors to NULL so they re-enter the parent +-- version's pending queue (search_tsv IS NULL) and its bounded sweep +-- rewrites them with 'simple'. Rows never re-vectorized still hold +-- valid 'simple' lexemes and are left untouched. This runs while the +-- replacement trigger functions (which ignore the search columns) are +-- still installed, so it does not advance message revisions or chat +-- history_version. UPDATE chat_messages SET search_tsv = NULL WHERE search_tsv_config = 'english'; ALTER TABLE chat_messages DROP COLUMN search_tsv_config; diff --git a/coderd/database/migrations/000585_chat_search_english_config.up.sql b/coderd/database/migrations/000585_chat_search_english_config.up.sql index 99f85a9437e..059fd5adb28 100644 --- a/coderd/database/migrations/000585_chat_search_english_config.up.sql +++ b/coderd/database/migrations/000585_chat_search_english_config.up.sql @@ -1,28 +1,19 @@ -- Switch chat message full-text search from the 'simple' to the -- 'english' text search config so queries match inflected forms --- (e.g. searching "refactor" matches "refactoring"). The config baked --- into a tsvector must match the config used by the tsquery at search --- time. --- --- Only chat_messages.search_tsv is affected; title and pull request --- title matching keeps its existing 'simple' behavior and indexes. --- chat_messages gets no index or data changes at all: a full-table --- UPDATE of search_tsv or a non-concurrent index rebuild would block --- message writes for the duration on large tables. Instead the new --- search_tsv_config column records which config produced each stored --- vector, and the bounded dbpurge sweep finds rows whose config is --- not 'english' with a self-terminating scan and rewrites them --- incrementally, newest first (see ReindexStaleChatMessagesSearchTsv). +-- (e.g. "refactor" matches "refactoring"). No data or index changes +-- here: rewriting search_tsv in a migration would block message +-- writes on large tables. The new search_tsv_config column records +-- which config produced each stored vector, and the bounded dbpurge +-- sweep rewrites stale rows incrementally, newest first (see +-- ReindexStaleChatMessagesSearchTsv). ALTER TABLE chat_messages ADD COLUMN search_tsv_config text; -COMMENT ON COLUMN chat_messages.search_tsv_config IS 'Text search config that produced search_tsv. NULL means the vector is stale (produced by an unknown config) and the row is pending re-vectorization. Binaries that predate this column cannot set it, so vectors written by an old replica during a rolling upgrade stay pending and are rewritten by an upgraded replica''s sweep.'; +COMMENT ON COLUMN chat_messages.search_tsv_config IS 'Text search config that produced search_tsv. NULL means an unknown config (a pre-migration vector or one written by an old binary); the dbpurge sweep re-vectorizes such rows.'; --- The sweep now also writes search_tsv_config, so both chatd trigger --- functions must exclude it (alongside search_tsv) when deciding --- whether a message change is meaningful. Without this every backfill --- batch would advance message revisions and chat history_version, --- waking chatd's processing loop for the whole backlog. +-- Both chatd trigger functions must exclude search_tsv_config +-- (alongside search_tsv) from their change comparisons so backfill +-- batches do not advance message revisions or chat history_version. CREATE OR REPLACE FUNCTION set_chat_message_revision_before() RETURNS trigger AS $$ diff --git a/coderd/database/models.go b/coderd/database/models.go index 665ab62ad8f..2b551815bee 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -5244,7 +5244,7 @@ type ChatMessage struct { ReasoningEffort NullChatReasoningEffort `db:"reasoning_effort" json:"reasoning_effort"` // Used for full text search. NULL initially, populated async via background job. SearchTsv interface{} `db:"search_tsv" json:"search_tsv"` - // Text search config that produced search_tsv. NULL means the vector is stale (produced by an unknown config) and the row is pending re-vectorization. Binaries that predate this column cannot set it, so vectors written by an old replica during a rolling upgrade stay pending and are rewritten by an upgraded replica's sweep. + // Text search config that produced search_tsv. NULL means an unknown config (a pre-migration vector or one written by an old binary); the dbpurge sweep re-vectorizes such rows. SearchTsvConfig sql.NullString `db:"search_tsv_config" json:"search_tsv_config"` } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 0b26813a273..3305199d4e3 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1350,19 +1350,11 @@ type sqlcQuerier interface { PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID) (ChatQueuedMessage, error) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error RegisterWorkspaceProxy(ctx context.Context, arg RegisterWorkspaceProxyParams) (WorkspaceProxy, error) - // Rewrites vectors produced with a stale text search config: rows - // indexed with 'simple' before migration 000585 and rows written by an - // old binary during a rolling upgrade (which cannot stamp - // search_tsv_config). This queue is deliberately unindexed. Stale rows - // are a one-time, shrinking backlog, so a permanent partial index (and - // its per-write maintenance) is not worth it, and rebuilding the - // pending index with a wider predicate in the migration would block - // message writes on large tables. ORDER BY id DESC with LIMIT walks - // the primary key backwards and terminates early while stale rows are - // dense, which is the entire drain; only the final near-empty pass - // costs a full walk, and the dbpurge caller stops calling this query - // for the process lifetime once a pass returns fewer rows than the - // batch size (proof the scan reached the end of the table). + // Rewrites vectors produced with a stale text search config ('simple' + // rows from before migration 000585, or rows written by an old binary + // mid rolling upgrade). Deliberately unindexed: this is a one-time, + // shrinking backlog drained newest first, so a permanent partial index + // is not worth its per-write maintenance. ReindexStaleChatMessagesSearchTsv(ctx context.Context, batchSize int32) (int64, error) // The lease is only removed if it is the current lease. ReleaseExternalAuthLinkRefreshLease(ctx context.Context, arg ReleaseExternalAuthLinkRefreshLeaseParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 94467675778..d93241ea8f8 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -9602,17 +9602,9 @@ WHERE AND cm.visibility IN ('user', 'both') AND cm.role IN ('user', 'assistant') -- Match each vector with the config that produced - -- it. Rows not yet re-vectorized by the sweep - -- (search_tsv_config IS NULL) hold 'simple' - -- lexemes, so an 'english' tsquery would miss them - -- (stored lexeme 'refactoring' never matches the - -- stemmed query 'refactor'). Querying stale rows - -- with 'simple' preserves their pre-migration - -- matching until the sweep rewrites them; once the - -- backlog drains the second arm matches no rows. - -- Both arms use constant tsqueries so the planner - -- can serve the OR as a bitmap over - -- idx_chat_messages_search_tsv. + -- it: rows not yet re-vectorized by the sweep + -- (config IS NULL) hold 'simple' lexemes that an + -- 'english' tsquery would miss. AND ( (cm.search_tsv_config = 'english' AND cm.search_tsv @@ websearch_to_tsquery('english', $16)) OR (cm.search_tsv_config IS NULL AND cm.search_tsv @@ websearch_to_tsquery('simple', $16)) @@ -11402,19 +11394,11 @@ SET search_tsv = COALESCE( FROM batch WHERE cm.id = batch.id ` -// Rewrites vectors produced with a stale text search config: rows -// indexed with 'simple' before migration 000585 and rows written by an -// old binary during a rolling upgrade (which cannot stamp -// search_tsv_config). This queue is deliberately unindexed. Stale rows -// are a one-time, shrinking backlog, so a permanent partial index (and -// its per-write maintenance) is not worth it, and rebuilding the -// pending index with a wider predicate in the migration would block -// message writes on large tables. ORDER BY id DESC with LIMIT walks -// the primary key backwards and terminates early while stale rows are -// dense, which is the entire drain; only the final near-empty pass -// costs a full walk, and the dbpurge caller stops calling this query -// for the process lifetime once a pass returns fewer rows than the -// batch size (proof the scan reached the end of the table). +// Rewrites vectors produced with a stale text search config ('simple' +// rows from before migration 000585, or rows written by an old binary +// mid rolling upgrade). Deliberately unindexed: this is a one-time, +// shrinking backlog drained newest first, so a permanent partial index +// is not worth its per-write maintenance. func (q *sqlQuerier) ReindexStaleChatMessagesSearchTsv(ctx context.Context, batchSize int32) (int64, error) { result, err := q.db.ExecContext(ctx, reindexStaleChatMessagesSearchTsv, batchSize) if err != nil { diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index c7d19b3e770..c243b45f0a3 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -339,19 +339,11 @@ SET search_tsv = COALESCE( FROM batch WHERE cm.id = batch.id; -- name: ReindexStaleChatMessagesSearchTsv :execrows --- Rewrites vectors produced with a stale text search config: rows --- indexed with 'simple' before migration 000585 and rows written by an --- old binary during a rolling upgrade (which cannot stamp --- search_tsv_config). This queue is deliberately unindexed. Stale rows --- are a one-time, shrinking backlog, so a permanent partial index (and --- its per-write maintenance) is not worth it, and rebuilding the --- pending index with a wider predicate in the migration would block --- message writes on large tables. ORDER BY id DESC with LIMIT walks --- the primary key backwards and terminates early while stale rows are --- dense, which is the entire drain; only the final near-empty pass --- costs a full walk, and the dbpurge caller stops calling this query --- for the process lifetime once a pass returns fewer rows than the --- batch size (proof the scan reached the end of the table). +-- Rewrites vectors produced with a stale text search config ('simple' +-- rows from before migration 000585, or rows written by an old binary +-- mid rolling upgrade). Deliberately unindexed: this is a one-time, +-- shrinking backlog drained newest first, so a permanent partial index +-- is not worth its per-write maintenance. WITH batch AS ( SELECT id FROM chat_messages WHERE search_tsv IS NOT NULL @@ -741,17 +733,9 @@ WHERE AND cm.visibility IN ('user', 'both') AND cm.role IN ('user', 'assistant') -- Match each vector with the config that produced - -- it. Rows not yet re-vectorized by the sweep - -- (search_tsv_config IS NULL) hold 'simple' - -- lexemes, so an 'english' tsquery would miss them - -- (stored lexeme 'refactoring' never matches the - -- stemmed query 'refactor'). Querying stale rows - -- with 'simple' preserves their pre-migration - -- matching until the sweep rewrites them; once the - -- backlog drains the second arm matches no rows. - -- Both arms use constant tsqueries so the planner - -- can serve the OR as a bitmap over - -- idx_chat_messages_search_tsv. + -- it: rows not yet re-vectorized by the sweep + -- (config IS NULL) hold 'simple' lexemes that an + -- 'english' tsquery would miss. AND ( (cm.search_tsv_config = 'english' AND cm.search_tsv @@ websearch_to_tsquery('english', @search)) OR (cm.search_tsv_config IS NULL AND cm.search_tsv @@ websearch_to_tsquery('simple', @search)) diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index cc2f693eaf2..9a773c9c56e 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -280,8 +280,6 @@ Message revision triggers depend on the transition invariant that `snapshot_vers A `BEFORE INSERT` trigger assigns the current chat `snapshot_version` to the inserted message row and records the same value as the chat's latest history version: - - ```sql CREATE FUNCTION set_chat_message_revision() RETURNS trigger AS $$ From 5af097f93ef0f20c176417324626674de7df1f3e Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Tue, 25 Aug 2026 11:33:04 +0000 Subject: [PATCH 13/16] refactor(coderd/database): use an enum for chat_messages.search_tsv_config --- coderd/database/dump.sql | 7 ++- ...000585_chat_search_english_config.down.sql | 2 + .../000585_chat_search_english_config.up.sql | 7 ++- coderd/database/models.go | 60 ++++++++++++++++++- coderd/database/queries.sql.go | 50 ++++++++-------- 5 files changed, 98 insertions(+), 28 deletions(-) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 4ae09927522..123388aea42 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -352,6 +352,11 @@ CREATE TYPE chat_message_role AS ENUM ( 'tool' ); +CREATE TYPE chat_message_search_tsv_config AS ENUM ( + 'simple', + 'english' +); + CREATE TYPE chat_message_visibility AS ENUM ( 'user', 'model', @@ -2055,7 +2060,7 @@ CREATE TABLE chat_messages ( revision bigint NOT NULL, reasoning_effort chat_reasoning_effort, search_tsv tsvector, - search_tsv_config text + search_tsv_config chat_message_search_tsv_config ); COMMENT ON COLUMN chat_messages.reasoning_effort IS 'Stores the selected effort for the turn triggered by this message.'; diff --git a/coderd/database/migrations/000585_chat_search_english_config.down.sql b/coderd/database/migrations/000585_chat_search_english_config.down.sql index 7e9c4dc10fa..b2e6469e535 100644 --- a/coderd/database/migrations/000585_chat_search_english_config.down.sql +++ b/coderd/database/migrations/000585_chat_search_english_config.down.sql @@ -12,6 +12,8 @@ UPDATE chat_messages SET search_tsv = NULL WHERE search_tsv_config = 'english'; ALTER TABLE chat_messages DROP COLUMN search_tsv_config; +DROP TYPE chat_message_search_tsv_config; + -- Restore the original trigger function bodies from 000545. CREATE OR REPLACE FUNCTION set_chat_message_revision_before() diff --git a/coderd/database/migrations/000585_chat_search_english_config.up.sql b/coderd/database/migrations/000585_chat_search_english_config.up.sql index 059fd5adb28..95b21a3fe55 100644 --- a/coderd/database/migrations/000585_chat_search_english_config.up.sql +++ b/coderd/database/migrations/000585_chat_search_english_config.up.sql @@ -7,7 +7,12 @@ -- sweep rewrites stale rows incrementally, newest first (see -- ReindexStaleChatMessagesSearchTsv). -ALTER TABLE chat_messages ADD COLUMN search_tsv_config text; +-- The Postgres text search configs this system has produced vectors +-- with. Extend with ALTER TYPE ... ADD VALUE if the config changes +-- again. +CREATE TYPE chat_message_search_tsv_config AS ENUM ('simple', 'english'); + +ALTER TABLE chat_messages ADD COLUMN search_tsv_config chat_message_search_tsv_config; COMMENT ON COLUMN chat_messages.search_tsv_config IS 'Text search config that produced search_tsv. NULL means an unknown config (a pre-migration vector or one written by an old binary); the dbpurge sweep re-vectorizes such rows.'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 2b551815bee..36378b125f0 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1568,6 +1568,64 @@ func AllChatMessageRoleValues() []ChatMessageRole { } } +type ChatMessageSearchTsvConfig string + +const ( + ChatMessageSearchTsvConfigSimple ChatMessageSearchTsvConfig = "simple" + ChatMessageSearchTsvConfigEnglish ChatMessageSearchTsvConfig = "english" +) + +func (e *ChatMessageSearchTsvConfig) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = ChatMessageSearchTsvConfig(s) + case string: + *e = ChatMessageSearchTsvConfig(s) + default: + return fmt.Errorf("unsupported scan type for ChatMessageSearchTsvConfig: %T", src) + } + return nil +} + +type NullChatMessageSearchTsvConfig struct { + ChatMessageSearchTsvConfig ChatMessageSearchTsvConfig `json:"chat_message_search_tsv_config"` + Valid bool `json:"valid"` // Valid is true if ChatMessageSearchTsvConfig is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullChatMessageSearchTsvConfig) Scan(value interface{}) error { + if value == nil { + ns.ChatMessageSearchTsvConfig, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.ChatMessageSearchTsvConfig.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullChatMessageSearchTsvConfig) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.ChatMessageSearchTsvConfig), nil +} + +func (e ChatMessageSearchTsvConfig) Valid() bool { + switch e { + case ChatMessageSearchTsvConfigSimple, + ChatMessageSearchTsvConfigEnglish: + return true + } + return false +} + +func AllChatMessageSearchTsvConfigValues() []ChatMessageSearchTsvConfig { + return []ChatMessageSearchTsvConfig{ + ChatMessageSearchTsvConfigSimple, + ChatMessageSearchTsvConfigEnglish, + } +} + type ChatMessageVisibility string const ( @@ -5245,7 +5303,7 @@ type ChatMessage struct { // Used for full text search. NULL initially, populated async via background job. SearchTsv interface{} `db:"search_tsv" json:"search_tsv"` // Text search config that produced search_tsv. NULL means an unknown config (a pre-migration vector or one written by an old binary); the dbpurge sweep re-vectorizes such rows. - SearchTsvConfig sql.NullString `db:"search_tsv_config" json:"search_tsv_config"` + SearchTsvConfig NullChatMessageSearchTsvConfig `db:"search_tsv_config" json:"search_tsv_config"` } type ChatModelConfig struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index d93241ea8f8..b9f3719d6b7 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -10795,31 +10795,31 @@ type InsertChatMessagesParams struct { } type InsertChatMessagesRow struct { - ID int64 `db:"id" json:"id"` - ChatID uuid.UUID `db:"chat_id" json:"chat_id"` - ModelConfigID uuid.NullUUID `db:"model_config_id" json:"model_config_id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - Role ChatMessageRole `db:"role" json:"role"` - Content pqtype.NullRawMessage `db:"content" json:"content"` - Visibility ChatMessageVisibility `db:"visibility" json:"visibility"` - InputTokens sql.NullInt64 `db:"input_tokens" json:"input_tokens"` - OutputTokens sql.NullInt64 `db:"output_tokens" json:"output_tokens"` - TotalTokens sql.NullInt64 `db:"total_tokens" json:"total_tokens"` - ReasoningTokens sql.NullInt64 `db:"reasoning_tokens" json:"reasoning_tokens"` - CacheCreationTokens sql.NullInt64 `db:"cache_creation_tokens" json:"cache_creation_tokens"` - CacheReadTokens sql.NullInt64 `db:"cache_read_tokens" json:"cache_read_tokens"` - ContextLimit sql.NullInt64 `db:"context_limit" json:"context_limit"` - Compressed bool `db:"compressed" json:"compressed"` - CreatedBy uuid.NullUUID `db:"created_by" json:"created_by"` - ContentVersion int16 `db:"content_version" json:"content_version"` - TotalCostMicros sql.NullInt64 `db:"total_cost_micros" json:"total_cost_micros"` - RuntimeMs sql.NullInt64 `db:"runtime_ms" json:"runtime_ms"` - Deleted bool `db:"deleted" json:"deleted"` - ProviderResponseID sql.NullString `db:"provider_response_id" json:"provider_response_id"` - Revision int64 `db:"revision" json:"revision"` - ReasoningEffort NullChatReasoningEffort `db:"reasoning_effort" json:"reasoning_effort"` - SearchTsv interface{} `db:"search_tsv" json:"search_tsv"` - SearchTsvConfig sql.NullString `db:"search_tsv_config" json:"search_tsv_config"` + ID int64 `db:"id" json:"id"` + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + ModelConfigID uuid.NullUUID `db:"model_config_id" json:"model_config_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + Role ChatMessageRole `db:"role" json:"role"` + Content pqtype.NullRawMessage `db:"content" json:"content"` + Visibility ChatMessageVisibility `db:"visibility" json:"visibility"` + InputTokens sql.NullInt64 `db:"input_tokens" json:"input_tokens"` + OutputTokens sql.NullInt64 `db:"output_tokens" json:"output_tokens"` + TotalTokens sql.NullInt64 `db:"total_tokens" json:"total_tokens"` + ReasoningTokens sql.NullInt64 `db:"reasoning_tokens" json:"reasoning_tokens"` + CacheCreationTokens sql.NullInt64 `db:"cache_creation_tokens" json:"cache_creation_tokens"` + CacheReadTokens sql.NullInt64 `db:"cache_read_tokens" json:"cache_read_tokens"` + ContextLimit sql.NullInt64 `db:"context_limit" json:"context_limit"` + Compressed bool `db:"compressed" json:"compressed"` + CreatedBy uuid.NullUUID `db:"created_by" json:"created_by"` + ContentVersion int16 `db:"content_version" json:"content_version"` + TotalCostMicros sql.NullInt64 `db:"total_cost_micros" json:"total_cost_micros"` + RuntimeMs sql.NullInt64 `db:"runtime_ms" json:"runtime_ms"` + Deleted bool `db:"deleted" json:"deleted"` + ProviderResponseID sql.NullString `db:"provider_response_id" json:"provider_response_id"` + Revision int64 `db:"revision" json:"revision"` + ReasoningEffort NullChatReasoningEffort `db:"reasoning_effort" json:"reasoning_effort"` + SearchTsv interface{} `db:"search_tsv" json:"search_tsv"` + SearchTsvConfig NullChatMessageSearchTsvConfig `db:"search_tsv_config" json:"search_tsv_config"` } // Returns the inserted rows in input array order. Ids are allocated before the From e8d715cc9fb5ebbed78ab596d9b719959fbd7cbf Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Tue, 25 Aug 2026 11:59:23 +0000 Subject: [PATCH 14/16] refactor(coderd/database/dbpurge): drop chatSearchStaleDrained field comment --- coderd/database/dbpurge/dbpurge.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/coderd/database/dbpurge/dbpurge.go b/coderd/database/dbpurge/dbpurge.go index 00cc88a6be9..38977e25148 100644 --- a/coderd/database/dbpurge/dbpurge.go +++ b/coderd/database/dbpurge/dbpurge.go @@ -449,8 +449,7 @@ type instance struct { chatSearchRowsBackfilled prometheus.Counter chatSearchBackfillBatchSize int32 chatSearchBackfillMaxBatches int - // Skips the stale-config reindex scan once its backlog is drained. - chatSearchStaleDrained bool + chatSearchStaleDrained bool } func (i *instance) Close() error { From 73ed324395cca7ff6228fd3f83071feaadaf7159 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Tue, 25 Aug 2026 12:04:19 +0000 Subject: [PATCH 15/16] refactor(coderd/database): drop query explanations from chats.sql --- coderd/database/querier.go | 13 +++---------- coderd/database/queries.sql.go | 23 ++++------------------- coderd/database/queries/chats.sql | 23 ++++------------------- 3 files changed, 11 insertions(+), 48 deletions(-) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 3305199d4e3..4bb5363e41f 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -69,13 +69,11 @@ type sqlcQuerier interface { // created_at ASC flows through to dbpurge's digest truncation; see // buildDigestData in dbpurge.go for the tradeoff rationale. AutoArchiveInactiveChats(ctx context.Context, arg AutoArchiveInactiveChatsParams) ([]AutoArchiveInactiveChatsRow, error) - // Backfills chat_messages.search_tsv for never-vectorized rows, newest - // first. The WHERE clause must match the predicate of + // Backfills chat_messages.search_tsv for pending rows, newest first. + // The WHERE clause must match the predicate of // idx_chat_messages_search_tsv_pending exactly so the partial index - // serves this query. Rows whose vector exists but was produced with a - // stale config are handled by ReindexStaleChatMessagesSearchTsv. + // serves this query. // NULL means "pending", empty tsvector means "backfilled, no text". - // search_tsv_config records the config that produced the vector. BackfillChatMessagesSearchTsv(ctx context.Context, batchSize int32) (int64, error) BackoffChatDiffStatus(ctx context.Context, arg BackoffChatDiffStatusParams) error // Deletes heartbeat rows for the supplied (chat_id, runner_id) pairs. @@ -1350,11 +1348,6 @@ type sqlcQuerier interface { PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID) (ChatQueuedMessage, error) ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx context.Context, templateID uuid.UUID) error RegisterWorkspaceProxy(ctx context.Context, arg RegisterWorkspaceProxyParams) (WorkspaceProxy, error) - // Rewrites vectors produced with a stale text search config ('simple' - // rows from before migration 000585, or rows written by an old binary - // mid rolling upgrade). Deliberately unindexed: this is a one-time, - // shrinking backlog drained newest first, so a permanent partial index - // is not worth its per-write maintenance. ReindexStaleChatMessagesSearchTsv(ctx context.Context, batchSize int32) (int64, error) // The lease is only removed if it is the current lease. ReleaseExternalAuthLinkRefreshLease(ctx context.Context, arg ReleaseExternalAuthLinkRefreshLeaseParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b9f3719d6b7..e9994339a4e 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -7416,13 +7416,11 @@ SET search_tsv = COALESCE( FROM batch WHERE cm.id = batch.id ` -// Backfills chat_messages.search_tsv for never-vectorized rows, newest -// first. The WHERE clause must match the predicate of +// Backfills chat_messages.search_tsv for pending rows, newest first. +// The WHERE clause must match the predicate of // idx_chat_messages_search_tsv_pending exactly so the partial index -// serves this query. Rows whose vector exists but was produced with a -// stale config are handled by ReindexStaleChatMessagesSearchTsv. +// serves this query. // NULL means "pending", empty tsvector means "backfilled, no text". -// search_tsv_config records the config that produced the vector. func (q *sqlQuerier) BackfillChatMessagesSearchTsv(ctx context.Context, batchSize int32) (int64, error) { result, err := q.db.ExecContext(ctx, backfillChatMessagesSearchTsv, batchSize) if err != nil { @@ -9574,11 +9572,7 @@ WHERE ELSE true END -- websearch_to_tsquery accepts quoted phrases, OR, and -negation; - -- the 'simple' config folds case and skips stemming. Message - -- bodies use the 'english' config instead, which also stems words - -- so inflected forms match (e.g. "refactor" matches - -- "refactoring"); the tsquery config must match the one baked into - -- each stored chat_messages.search_tsv vector. + -- the 'simple' config folds case and skips stemming. AND CASE WHEN $16::text != '' THEN ( -- Served by idx_chats_title_fts. @@ -9601,10 +9595,6 @@ WHERE AND cm.deleted = false AND cm.visibility IN ('user', 'both') AND cm.role IN ('user', 'assistant') - -- Match each vector with the config that produced - -- it: rows not yet re-vectorized by the sweep - -- (config IS NULL) hold 'simple' lexemes that an - -- 'english' tsquery would miss. AND ( (cm.search_tsv_config = 'english' AND cm.search_tsv @@ websearch_to_tsquery('english', $16)) OR (cm.search_tsv_config IS NULL AND cm.search_tsv @@ websearch_to_tsquery('simple', $16)) @@ -11394,11 +11384,6 @@ SET search_tsv = COALESCE( FROM batch WHERE cm.id = batch.id ` -// Rewrites vectors produced with a stale text search config ('simple' -// rows from before migration 000585, or rows written by an old binary -// mid rolling upgrade). Deliberately unindexed: this is a one-time, -// shrinking backlog drained newest first, so a permanent partial index -// is not worth its per-write maintenance. func (q *sqlQuerier) ReindexStaleChatMessagesSearchTsv(ctx context.Context, batchSize int32) (int64, error) { result, err := q.db.ExecContext(ctx, reindexStaleChatMessagesSearchTsv, batchSize) if err != nil { diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index c243b45f0a3..f595b135618 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -315,11 +315,10 @@ WHERE id = @id::bigint; -- name: BackfillChatMessagesSearchTsv :execrows --- Backfills chat_messages.search_tsv for never-vectorized rows, newest --- first. The WHERE clause must match the predicate of +-- Backfills chat_messages.search_tsv for pending rows, newest first. +-- The WHERE clause must match the predicate of -- idx_chat_messages_search_tsv_pending exactly so the partial index --- serves this query. Rows whose vector exists but was produced with a --- stale config are handled by ReindexStaleChatMessagesSearchTsv. +-- serves this query. WITH batch AS ( SELECT id FROM chat_messages WHERE search_tsv IS NULL @@ -331,7 +330,6 @@ WITH batch AS ( ) UPDATE chat_messages cm -- NULL means "pending", empty tsvector means "backfilled, no text". --- search_tsv_config records the config that produced the vector. SET search_tsv = COALESCE( to_tsvector('english', chat_message_search_text(cm.content)), ''::tsvector), @@ -339,11 +337,6 @@ SET search_tsv = COALESCE( FROM batch WHERE cm.id = batch.id; -- name: ReindexStaleChatMessagesSearchTsv :execrows --- Rewrites vectors produced with a stale text search config ('simple' --- rows from before migration 000585, or rows written by an old binary --- mid rolling upgrade). Deliberately unindexed: this is a one-time, --- shrinking backlog drained newest first, so a permanent partial index --- is not worth its per-write maintenance. WITH batch AS ( SELECT id FROM chat_messages WHERE search_tsv IS NOT NULL @@ -705,11 +698,7 @@ WHERE ELSE true END -- websearch_to_tsquery accepts quoted phrases, OR, and -negation; - -- the 'simple' config folds case and skips stemming. Message - -- bodies use the 'english' config instead, which also stems words - -- so inflected forms match (e.g. "refactor" matches - -- "refactoring"); the tsquery config must match the one baked into - -- each stored chat_messages.search_tsv vector. + -- the 'simple' config folds case and skips stemming. AND CASE WHEN @search::text != '' THEN ( -- Served by idx_chats_title_fts. @@ -732,10 +721,6 @@ WHERE AND cm.deleted = false AND cm.visibility IN ('user', 'both') AND cm.role IN ('user', 'assistant') - -- Match each vector with the config that produced - -- it: rows not yet re-vectorized by the sweep - -- (config IS NULL) hold 'simple' lexemes that an - -- 'english' tsquery would miss. AND ( (cm.search_tsv_config = 'english' AND cm.search_tsv @@ websearch_to_tsquery('english', @search)) OR (cm.search_tsv_config IS NULL AND cm.search_tsv @@ websearch_to_tsquery('simple', @search)) From a2459f8dda0583b9d73b9e77863a0a6ea0a845de Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Tue, 25 Aug 2026 12:54:18 +0000 Subject: [PATCH 16/16] fix(coderd/database): stamp existing vectors simple and match config explicitly --- coderd/database/dbpurge/dbpurge_test.go | 27 +++++++++++-------- coderd/database/dump.sql | 2 +- .../000585_chat_search_english_config.up.sql | 18 ++++++++----- coderd/database/models.go | 2 +- coderd/database/querier_test.go | 22 ++++++++++----- coderd/database/queries.sql.go | 2 +- coderd/database/queries/chats.sql | 2 +- 7 files changed, 46 insertions(+), 29 deletions(-) diff --git a/coderd/database/dbpurge/dbpurge_test.go b/coderd/database/dbpurge/dbpurge_test.go index 594e65f093a..4e89b97f301 100644 --- a/coderd/database/dbpurge/dbpurge_test.go +++ b/coderd/database/dbpurge/dbpurge_test.go @@ -3311,21 +3311,25 @@ func TestBackfillChatMessagesSearchTsv(t *testing.T) { require.NoError(t, err) return count } - makeStale := func(id int64) { - // Simulates a vector produced before the 'english' switch or by - // a binary that predates search_tsv_config (e.g. an old replica - // winning the dbpurge lock mid rolling upgrade). + makeStale := func(id int64, config sql.NullString) { + // 'simple' simulates a vector stamped by migration 000585; NULL + // simulates a binary that predates search_tsv_config (e.g. an + // old replica winning the dbpurge lock mid rolling upgrade). _, err := rawDB.ExecContext(ctx, ` UPDATE chat_messages SET search_tsv = to_tsvector('simple', chat_message_search_text(content)), - search_tsv_config = NULL - WHERE id = $1`, id) + search_tsv_config = $2 + WHERE id = $1`, id, config) require.NoError(t, err) } + simpleConfig := sql.NullString{String: "simple", Valid: true} + nullConfig := sql.NullString{} preexisting := createMessage(t, db, deps, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, textContent("refactoring the deployment")) - makeStale(preexisting.ID) - require.Equal(t, 1, countStale()) + makeStale(preexisting.ID, simpleConfig) + oldBinary := createMessage(t, db, deps, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, textContent("rotating the credentials")) + makeStale(oldBinary.ID, nullConfig) + require.Equal(t, 2, countStale()) tick := awaitDoTicks(ctx, t, clk, 2) closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), dbpurge.WithClock(clk)) @@ -3334,13 +3338,14 @@ func TestBackfillChatMessagesSearchTsv(t *testing.T) { // latches off the stale scan for the process lifetime. tick() requireTsvFor(ctx, t, rawDB, preexisting.ID, "refactoring the deployment") + requireTsvFor(ctx, t, rawDB, oldBinary.ID, "rotating the credentials") require.Zero(t, countStale()) // A stale row appearing after the latch (old replica racing the // tail of a rolling upgrade) is intentionally not rewritten by - // this process; it stays correctly searchable via its recorded - // config until a restart re-runs one stale pass. - makeStale(preexisting.ID) + // this process; it is repaired after a restart re-runs one stale + // pass. + makeStale(preexisting.ID, nullConfig) tick() require.Equal(t, 1, countStale(), "stale scan must stay latched off after drain") require.NoError(t, closer.Close()) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 123388aea42..c059927c6a6 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -2067,7 +2067,7 @@ COMMENT ON COLUMN chat_messages.reasoning_effort IS 'Stores the selected effort COMMENT ON COLUMN chat_messages.search_tsv IS 'Used for full text search. NULL initially, populated async via background job.'; -COMMENT ON COLUMN chat_messages.search_tsv_config IS 'Text search config that produced search_tsv. NULL means an unknown config (a pre-migration vector or one written by an old binary); the dbpurge sweep re-vectorizes such rows.'; +COMMENT ON COLUMN chat_messages.search_tsv_config IS 'Text search config that produced search_tsv. NULL means the vector was written by a binary that predates this column; the dbpurge sweep re-vectorizes rows whose config is not english.'; CREATE SEQUENCE chat_messages_id_seq START WITH 1 diff --git a/coderd/database/migrations/000585_chat_search_english_config.up.sql b/coderd/database/migrations/000585_chat_search_english_config.up.sql index 95b21a3fe55..ccc0c51056a 100644 --- a/coderd/database/migrations/000585_chat_search_english_config.up.sql +++ b/coderd/database/migrations/000585_chat_search_english_config.up.sql @@ -1,11 +1,10 @@ -- Switch chat message full-text search from the 'simple' to the -- 'english' text search config so queries match inflected forms --- (e.g. "refactor" matches "refactoring"). No data or index changes --- here: rewriting search_tsv in a migration would block message --- writes on large tables. The new search_tsv_config column records --- which config produced each stored vector, and the bounded dbpurge --- sweep rewrites stale rows incrementally, newest first (see --- ReindexStaleChatMessagesSearchTsv). +-- (e.g. "refactor" matches "refactoring"). search_tsv itself is not +-- rewritten here (that would block message writes on large tables); +-- the new search_tsv_config column records which config produced each +-- stored vector, and the bounded dbpurge sweep rewrites stale rows +-- incrementally, newest first (see ReindexStaleChatMessagesSearchTsv). -- The Postgres text search configs this system has produced vectors -- with. Extend with ALTER TYPE ... ADD VALUE if the config changes @@ -14,7 +13,7 @@ CREATE TYPE chat_message_search_tsv_config AS ENUM ('simple', 'english'); ALTER TABLE chat_messages ADD COLUMN search_tsv_config chat_message_search_tsv_config; -COMMENT ON COLUMN chat_messages.search_tsv_config IS 'Text search config that produced search_tsv. NULL means an unknown config (a pre-migration vector or one written by an old binary); the dbpurge sweep re-vectorizes such rows.'; +COMMENT ON COLUMN chat_messages.search_tsv_config IS 'Text search config that produced search_tsv. NULL means the vector was written by a binary that predates this column; the dbpurge sweep re-vectorizes rows whose config is not english.'; -- Both chatd trigger functions must exclude search_tsv_config -- (alongside search_tsv) from their change comparisons so backfill @@ -83,3 +82,8 @@ END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION update_chat_history_after_message_update IS 'Component of chatd. Updates history_version and generation_attempt on chats when chat_messages is updated. Excludes changes to search_tsv and search_tsv_config.'; + +-- Stamp existing vectors with the config that produced them. Runs +-- after the trigger replacements above so it does not advance message +-- revisions or chat history_version. +UPDATE chat_messages SET search_tsv_config = 'simple' WHERE search_tsv IS NOT NULL; diff --git a/coderd/database/models.go b/coderd/database/models.go index 36378b125f0..40755771fc2 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -5302,7 +5302,7 @@ type ChatMessage struct { ReasoningEffort NullChatReasoningEffort `db:"reasoning_effort" json:"reasoning_effort"` // Used for full text search. NULL initially, populated async via background job. SearchTsv interface{} `db:"search_tsv" json:"search_tsv"` - // Text search config that produced search_tsv. NULL means an unknown config (a pre-migration vector or one written by an old binary); the dbpurge sweep re-vectorizes such rows. + // Text search config that produced search_tsv. NULL means the vector was written by a binary that predates this column; the dbpurge sweep re-vectorizes rows whose config is not english. SearchTsvConfig NullChatMessageSearchTsvConfig `db:"search_tsv_config" json:"search_tsv_config"` } diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 26692b6f3fa..6d82c145d90 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -18333,18 +18333,25 @@ func TestGetChatsSearch(t *testing.T) { pendingChat := createRoot("plain four") insertMsg(pendingChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "elasticsearch indexing") - // Simulates the post-migration drain window: a vector produced with - // the 'simple' config whose search_tsv_config was never stamped - // (pre-migration backfill or an old binary mid rolling upgrade). - // Such rows must keep their pre-migration exact-form matching until - // the sweep rewrites them. + // Simulates the post-migration drain window: a vector stamped + // 'simple' by the migration keeps exact-form matching until the + // sweep rewrites it. staleChat := createRoot("plain stale") staleMsg := insertMsg(staleChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "refactoring codebase") _, err = sqlDB.ExecContext(ctx, - `UPDATE chat_messages SET search_tsv = to_tsvector('simple', 'refactoring codebase'), search_tsv_config = NULL WHERE id = $1`, + `UPDATE chat_messages SET search_tsv = to_tsvector('simple', 'refactoring codebase'), search_tsv_config = 'simple' WHERE id = $1`, staleMsg.ID) require.NoError(t, err) + // A vector written by a binary that predates search_tsv_config + // (NULL config) matches nothing until the sweep rewrites it. + oldBinaryChat := createRoot("plain oldbinary") + oldBinaryMsg := insertMsg(oldBinaryChat.ID, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "quantum entanglement notes") + _, err = sqlDB.ExecContext(ctx, + `UPDATE chat_messages SET search_tsv = to_tsvector('simple', 'quantum entanglement notes'), search_tsv_config = NULL WHERE id = $1`, + oldBinaryMsg.ID) + require.NoError(t, err) + // Prove role/visibility predicates exclude rows even when search_tsv // is set. _, err = sqlDB.ExecContext(ctx, @@ -18359,7 +18366,7 @@ func TestGetChatsSearch(t *testing.T) { titleChat.ID, archivedChat.ID, prTitleChat.ID, mergedChat.ID, msgChat.ID, assistantMsgChat.ID, userVisMsgChat.ID, assistantUserVisMsgChat.ID, deletedMsgChat.ID, childParent.ID, - ineligibleChat.ID, pendingChat.ID, staleChat.ID, + ineligibleChat.ID, pendingChat.ID, staleChat.ID, oldBinaryChat.ID, } tests := []struct { @@ -18384,6 +18391,7 @@ func TestGetChatsSearch(t *testing.T) { // does not until the sweep rewrites the row. {"Message/StaleVectorExactFormMatch", database.GetChatsParams{Search: "refactoring codebase"}, []uuid.UUID{staleChat.ID}}, {"Message/StaleVectorNoStemming", database.GetChatsParams{Search: "refactor"}, nil}, + {"Message/NullConfigNoMatch", database.GetChatsParams{Search: "quantum entanglement"}, nil}, {"Message/AssistantRoleMatch", database.GetChatsParams{Search: "grafana tuning"}, []uuid.UUID{assistantMsgChat.ID}}, {"Message/UserVisibilityMatch", database.GetChatsParams{Search: "vault rotation"}, []uuid.UUID{userVisMsgChat.ID}}, {"Message/AssistantUserVisibilityMatch", database.GetChatsParams{Search: "redis eviction"}, []uuid.UUID{assistantUserVisMsgChat.ID}}, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e9994339a4e..b6151da9d2d 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -9597,7 +9597,7 @@ WHERE AND cm.role IN ('user', 'assistant') AND ( (cm.search_tsv_config = 'english' AND cm.search_tsv @@ websearch_to_tsquery('english', $16)) - OR (cm.search_tsv_config IS NULL AND cm.search_tsv @@ websearch_to_tsquery('simple', $16)) + OR (cm.search_tsv_config = 'simple' AND cm.search_tsv @@ websearch_to_tsquery('simple', $16)) ) ) -- Skip an explicit pr_number lookup unless the search is a valid bigint. diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index f595b135618..be12113e873 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -723,7 +723,7 @@ WHERE AND cm.role IN ('user', 'assistant') AND ( (cm.search_tsv_config = 'english' AND cm.search_tsv @@ websearch_to_tsquery('english', @search)) - OR (cm.search_tsv_config IS NULL AND cm.search_tsv @@ websearch_to_tsquery('simple', @search)) + OR (cm.search_tsv_config = 'simple' AND cm.search_tsv @@ websearch_to_tsquery('simple', @search)) ) ) -- Skip an explicit pr_number lookup unless the search is a valid bigint.