mirror of
https://github.com/coder/coder.git
synced 2026-11-01 04:47:17 +08:00
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:
@@ -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})
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Generated
+15
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+13
-4
@@ -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;
|
||||
@@ -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()
|
||||
|
||||
|
||||
Generated
+60
@@ -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 {
|
||||
|
||||
Generated
+1
@@ -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)
|
||||
|
||||
@@ -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}},
|
||||
|
||||
Generated
+76
-36
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user