diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d6b90f982e2..84bdcd7f833 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; 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; 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 0cb630c4460..03e9e31c92d 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; 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; 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/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 01c525c677c..7dd08483218 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -7193,6 +7193,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 4050b917424..0da0753cdb9 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 c500907e35d..abd442fae52 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -5032,6 +5032,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 71384ea7b7c..4296f49af85 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -9564,6 +9564,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..38977e25148 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,28 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time. } } + // 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 { + 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 +425,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 +449,7 @@ type instance struct { chatSearchRowsBackfilled prometheus.Counter chatSearchBackfillBatchSize int32 chatSearchBackfillMaxBatches int + chatSearchStaleDrained bool } func (i *instance) Close() error { diff --git a/coderd/database/dbpurge/dbpurge_test.go b/coderd/database/dbpurge/dbpurge_test.go index c369e3f4048..4e89b97f301 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")). @@ -3113,15 +3115,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('simple', $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() @@ -3284,6 +3290,76 @@ 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) + + 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, 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 = $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, 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)) + + // The first tick rewrites the stale backlog with 'english' and + // 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 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()) + + // 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, preexisting.ID, "refactoring the deployment") + require.Zero(t, countStale()) + }) + //nolint:paralleltest // It uses LockIDDBPurge. t.Run("SteadyStateNoop", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) @@ -3341,6 +3417,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) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 905b4d00823..c059927c6a6 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', @@ -1479,6 +1484,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; @@ -1496,7 +1502,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 @@ -1548,7 +1554,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 ( @@ -1559,7 +1565,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, @@ -2053,13 +2059,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 chat_message_search_tsv_config ); 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 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 INCREMENT BY 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 new file mode 100644 index 00000000000..b2e6469e535 --- /dev/null +++ b/coderd/database/migrations/000585_chat_search_english_config.down.sql @@ -0,0 +1,80 @@ +-- Revert chat message full-text search to the 'simple' text search +-- config. + +-- 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; + +DROP TYPE chat_message_search_tsv_config; + +-- 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/000585_chat_search_english_config.up.sql b/coderd/database/migrations/000585_chat_search_english_config.up.sql new file mode 100644 index 00000000000..ccc0c51056a --- /dev/null +++ b/coderd/database/migrations/000585_chat_search_english_config.up.sql @@ -0,0 +1,89 @@ +-- 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"). 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 +-- 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 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 +-- batches do not advance message revisions or chat history_version. + +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.'; + +-- 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/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 53050bb8ed7..42531844b72 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -2513,15 +2513,55 @@ 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 that produced the vector. _, 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), + 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 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.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, 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")) require.Equal(t, []int64{unswept.ID}, pendingIDs(ctx, 10)) @@ -2533,13 +2573,14 @@ 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), + search_tsv_config = 'english' 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") @@ -2557,6 +2598,108 @@ func TestMigration000543ChatSearchSchemaBehavior(t *testing.T) { toolMsg.ID, modelOnly.ID, deletedMsg.ID) } +func TestMigration000585ChatSearchEnglishConfigDown(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("000585_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() diff --git a/coderd/database/models.go b/coderd/database/models.go index 3e1464dc07f..40755771fc2 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 ( @@ -5244,6 +5302,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 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"` } type ChatModelConfig struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index f5d3a4ccf7f..2cd4e268ac8 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1353,6 +1353,7 @@ 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) + 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/querier_test.go b/coderd/database/querier_test.go index 39e84ae1b72..de833c76461 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -18335,10 +18335,29 @@ 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 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 = '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, - `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) @@ -18349,7 +18368,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, oldBinaryChat.ID, } tests := []struct { @@ -18360,8 +18379,21 @@ 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}, + // 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/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. + {"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/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 5d5e49d5a27..b9907840ca7 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -7410,8 +7410,9 @@ WITH batch AS ( ) UPDATE chat_messages cm SET search_tsv = COALESCE( - to_tsvector('simple', 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 ` @@ -8449,7 +8450,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 @@ -8485,6 +8486,7 @@ func (q *sqlQuerier) GetChatMessageByID(ctx context.Context, id int64) (ChatMess &i.Revision, &i.ReasoningEffort, &i.SearchTsv, + &i.SearchTsvConfig, ) return i, err } @@ -8571,7 +8573,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 @@ -8625,6 +8627,7 @@ func (q *sqlQuerier) GetChatMessagesByChatID(ctx context.Context, arg GetChatMes &i.Revision, &i.ReasoningEffort, &i.SearchTsv, + &i.SearchTsvConfig, ); err != nil { return nil, err } @@ -8641,7 +8644,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 @@ -8695,6 +8698,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDAscPaginated(ctx context.Context, ar &i.Revision, &i.ReasoningEffort, &i.SearchTsv, + &i.SearchTsvConfig, ); err != nil { return nil, err } @@ -8711,7 +8715,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 @@ -8778,6 +8782,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDDescPaginated(ctx context.Context, a &i.Revision, &i.ReasoningEffort, &i.SearchTsv, + &i.SearchTsvConfig, ); err != nil { return nil, err } @@ -8794,7 +8799,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 @@ -8845,6 +8850,7 @@ func (q *sqlQuerier) GetChatMessagesByRevisionForStream(ctx context.Context, arg &i.Revision, &i.ReasoningEffort, &i.SearchTsv, + &i.SearchTsvConfig, ); err != nil { return nil, err } @@ -8876,7 +8882,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 @@ -8952,6 +8958,7 @@ func (q *sqlQuerier) GetChatMessagesForPromptByChatID(ctx context.Context, chatI &i.Revision, &i.ReasoningEffort, &i.SearchTsv, + &i.SearchTsvConfig, ); err != nil { return nil, err } @@ -9588,7 +9595,10 @@ 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_config = 'english' AND cm.search_tsv @@ websearch_to_tsquery('english', $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. OR CASE @@ -10182,7 +10192,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 @@ -10230,6 +10240,7 @@ func (q *sqlQuerier) GetLastChatMessageByRole(ctx context.Context, arg GetLastCh &i.Revision, &i.ReasoningEffort, &i.SearchTsv, + &i.SearchTsvConfig, ) return i, err } @@ -10746,9 +10757,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 ` @@ -10774,30 +10785,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"` + 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 @@ -10855,6 +10867,7 @@ func (q *sqlQuerier) InsertChatMessages(ctx context.Context, arg InsertChatMessa &i.Revision, &i.ReasoningEffort, &i.SearchTsv, + &i.SearchTsvConfig, ); err != nil { return nil, err } @@ -11352,6 +11365,33 @@ 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 +` + +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 7f6d667bea1..be12113e873 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -331,8 +331,27 @@ 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)), - ''::tsvector) + to_tsvector('english', chat_message_search_text(cm.content)), + ''::tsvector), + search_tsv_config = 'english' +FROM batch WHERE cm.id = batch.id; + +-- 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 @batch_size::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; -- name: GetChatByID :one @@ -702,7 +721,10 @@ 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_config = 'english' AND cm.search_tsv @@ websearch_to_tsquery('english', @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. OR CASE diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 00f2d2d132f..a7a093e3987 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -400,7 +400,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; 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/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 9bc5b4211bd..a906efd7f32 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -2986,24 +2986,42 @@ 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. - 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 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) 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..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('simple', 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) diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index dcd00f33d18..4c780792494 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; 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