fix: stem chat search terms with the english text search config (#28319)

Chat message search used the `simple` text search config, which folds
case but does no stemming, so `refactor` did not match messages
containing `refactoring`. Message bodies now index and query with the
`english` config, which stems both sides. Titles, PR titles, and all
other filters are unchanged.

Migration `000585` adds a `chat_messages.search_tsv_config` enum column
recording which config produced each stored vector and stamps existing
vectors `'simple'` (a column-only update; vectors and indexes are
untouched, since rewriting `search_tsv` would block message writes on
large tables). The dbpurge sweep gains a bounded
`ReindexStaleChatMessagesSearchTsv` pass that rewrites stale vectors
newest first and stops for the process lifetime once drained. Until a
row is rewritten, `GetChats` matches it with the config that produced
it, so pre-migration exact-form searches keep working during the drain.
Vectors written by old binaries mid rolling upgrade cannot stamp the
config, stay pending, and self-heal on the next upgraded sweep.
Steady-state DB load is unchanged.

The down migration resets `english` vectors to `NULL` so the parent
version's existing sweep rewrites them with `simple`.

Closes
[CODAGT-867](https://linear.app/codercom/issue/CODAGT-867/backend-search-improvements-to-handle-partial-matches)

<details>
<summary>Decision log</summary>

- **`english` stemming over prefix matching.** On a 300k-row corpus a
stemmed lookup is a single GIN entry-tree probe, while `term:*` prefix
matching unions posting lists of every lexeme sharing the prefix (up to
a full scan for short prefixes), is asymmetric, and loses the
`websearch_to_tsquery` phrase/OR/negation UX. `pg_trgm` rejected as a
heavier index with different semantics.
- **Scope to message bodies only** (review). Titles and PR titles keep
their `simple` FTS expressions and indexes, identical to main.
- **Config-versioned queue over migration-time reset** (review). An
unqualified `UPDATE ... SET search_tsv = NULL` rewrites every indexed
row in the migration transaction, and an old replica could backfill
reset rows with `simple` vectors that permanently leave the `search_tsv
IS NULL` queue. The config column makes staleness explicit and both
problems disappear.
- **Enum over `text`** (review) for the config column.
- **Trade-offs.** English stopwords are dropped from message search
(stopword-only queries match no messages); stemming is English-specific;
titles do not stem; prefix matching remains out of scope.

</details>

---

🤖 This PR was generated by Coder Agents on behalf of @f0ssel.
This commit is contained in:
Garrett Delfosse
2026-08-25 09:08:52 -04:00
committed by GitHub
parent 6284964b5f
commit 378ac91b49
21 changed files with 703 additions and 67 deletions
+7
View File
@@ -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})
+4
View File
@@ -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()
+8
View File
@@ -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)
+15
View File
@@ -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()
+30
View File
@@ -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 {
+80 -3
View File
@@ -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)
+13 -4
View File
@@ -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
@@ -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.';
@@ -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;
+148 -5
View File
@@ -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()
+60
View File
@@ -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 {
+1
View File
@@ -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)
+34 -2
View File
@@ -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}},
+76 -36
View File
@@ -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 = (
+25 -3
View File
@@ -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