Skip to content

Commit 3841b94

Browse files
fix(search): retire indexed content across all Search KBs (#8441)
* fix(search): retire indexed content across all Search KBs * fix(search): validate retirement scope through completion
1 parent 3ab3459 commit 3841b94

6 files changed

Lines changed: 333 additions & 93 deletions

‎packages/db/drizzle.config.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,9 @@ export default {
1616
url: process.env.DATABASE_URL!,
1717
},
1818
/** Runner-owned journals and resumable cleanup cursors must survive a development schema push. */
19-
tablesFilter: ['!script_migrations', '!search_embedding_cleanup_progress'],
19+
tablesFilter: [
20+
'!script_migrations',
21+
'!search_embedding_cleanup_progress',
22+
'!search_embedding_cleanup_targets',
23+
],
2024
} satisfies Config

‎packages/db/script-migrations/0027_retire_search_embeddings.integration.ts‎

Lines changed: 153 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { retireSearchEmbeddingsMigration } from '@sim/db/script-migrations/0027_retire_search_embeddings'
22
import { maintainSearchRetirementMigration } from '@sim/db/script-migrations/0028_maintain_search_retirement'
3-
import { runScriptMigrations } from '@sim/db/script-migrations/index'
3+
import { runScriptMigrations, scriptMigrations } from '@sim/db/script-migrations/index'
44
import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
55
import { sleep } from '@sim/utils/helpers'
66
import { generateId } from '@sim/utils/id'
@@ -18,6 +18,8 @@ describe('retiring dormant Search embeddings', () => {
1818
await admin.unsafe(`CREATE SCHEMA "${schema}"`)
1919
sql = postgres(readTestDatabaseUrl(), {
2020
max: 1,
21+
/** Fixtures alternate legacy and upgraded table layouts on this connection. */
22+
prepare: false,
2123
connection: { search_path: schema },
2224
onnotice: () => undefined,
2325
})
@@ -46,6 +48,7 @@ describe('retiring dormant Search embeddings', () => {
4648
beforeEach(async () => {
4749
await sql`TRUNCATE knowledge_base, document, embedding, embedding_search,
4850
embedding_keyword_search, embedding_keyword_tin, embedding_secret_provenance`
51+
await sql`DROP TABLE IF EXISTS search_embedding_cleanup_targets`
4952
await sql`DROP TABLE IF EXISTS search_embedding_cleanup_progress`
5053
await sql`DROP TABLE IF EXISTS script_migrations`
5154
await sql`INSERT INTO knowledge_base VALUES ('search', true), ('ordinary', false)`
@@ -68,20 +71,25 @@ describe('retiring dormant Search embeddings', () => {
6871
return receipts.length === 1
6972
}
7073

71-
it('does nothing without Search data and fails an ambiguous target without changing data', async () => {
74+
it('does nothing without Search data', async () => {
7275
await sql`UPDATE knowledge_base SET is_search_index = false`
7376
expect(await pass()).toBe(true)
74-
await sql`DELETE FROM script_migrations`
75-
await sql`UPDATE knowledge_base SET is_search_index = true`
76-
await expect(pass()).rejects.toThrow('ambiguous')
77-
expect(await sql`SELECT name FROM script_migrations`).toHaveLength(0)
7877
expect((await sql`SELECT count(*)::int AS n FROM embedding`)[0].n).toBe(1002)
7978
expect(
8079
(await sql`SELECT user_excluded FROM document WHERE id = 'search-doc'`)[0].user_excluded
8180
).toBe(false)
8281
})
8382

84-
it('cascades only Search chunks and preserves configuration and ordinary documents', async () => {
83+
it('retires every Search KB while preserving configuration and ordinary documents', async () => {
84+
await sql`INSERT INTO knowledge_base VALUES ('second-search', true), ('empty-search', true)`
85+
await sql`INSERT INTO document (id, knowledge_base_id, processing_queue_token)
86+
VALUES ('second-search-doc', 'second-search', 'old-dispatch')`
87+
await sql`INSERT INTO embedding VALUES
88+
('00000', 'second-search', 'second-search-doc'), ('zz-last', 'second-search', 'second-search-doc')`
89+
await sql`INSERT INTO embedding_search (id) VALUES ('00000'), ('zz-last')`
90+
await sql`INSERT INTO embedding_keyword_search VALUES ('00000'), ('zz-last')`
91+
await sql`INSERT INTO embedding_keyword_tin VALUES ('00000'), ('zz-last')`
92+
await sql`INSERT INTO embedding_secret_provenance VALUES ('00000'), ('zz-last')`
8593
expect(await pass()).toBe(true)
8694
expect(
8795
(
@@ -103,7 +111,14 @@ describe('retiring dormant Search embeddings', () => {
103111
]) {
104112
expect((await sql.unsafe(`SELECT count(*)::int AS n FROM ${table}`))[0].n).toBe(501)
105113
}
106-
expect((await sql`SELECT count(*)::int AS n FROM knowledge_base`)[0].n).toBe(2)
114+
expect((await sql`SELECT count(*)::int AS n FROM knowledge_base`)[0].n).toBe(4)
115+
expect((await sql`SELECT count(*)::int AS n FROM embedding`)[0].n).toBe(501)
116+
expect(
117+
(
118+
await sql`SELECT user_excluded, enabled, processing_queue_token FROM document
119+
WHERE id = 'second-search-doc'`
120+
)[0]
121+
).toEqual({ user_excluded: true, enabled: false, processing_queue_token: null })
107122
expect(
108123
(
109124
await sql`SELECT user_excluded, processing_queue_token FROM document WHERE id = 'ordinary-doc'`
@@ -112,9 +127,134 @@ describe('retiring dormant Search embeddings', () => {
112127
expect(await pass()).toBe(true)
113128
})
114129

115-
it('rolls back failed pages and resumes the frozen target, retiring documents inserted behind the cursor', async () => {
130+
it.each([
131+
{ phase: 'documents', legacy: 'present', otherSearch: true },
132+
{ phase: 'embeddings', legacy: 'present', otherSearch: true },
133+
{ phase: 'done', legacy: 'present', otherSearch: true },
134+
{ phase: 'done', legacy: 'deleted', otherSearch: true },
135+
{ phase: 'done', legacy: 'ordinary', otherSearch: true },
136+
{ phase: 'done', legacy: 'deleted', otherSearch: false },
137+
] as const)(
138+
'upgrades a legacy $phase checkpoint with a $legacy KB (other Search KBs: $otherSearch)',
139+
async ({ phase, legacy, otherSearch }) => {
140+
if (otherSearch) {
141+
await sql`INSERT INTO knowledge_base VALUES ('second-search', true)`
142+
await sql`INSERT INTO document (id, knowledge_base_id)
143+
VALUES ('aaa-second-doc', 'second-search')`
144+
await sql`INSERT INTO embedding VALUES ('00000', 'second-search', 'aaa-second-doc')`
145+
await sql`INSERT INTO embedding_search (id) VALUES ('00000')`
146+
}
147+
await sql`UPDATE document SET user_excluded = true, enabled = false,
148+
processing_queue_token = NULL WHERE knowledge_base_id = 'search'`
149+
await sql`CREATE TABLE search_embedding_cleanup_progress (
150+
id integer PRIMARY KEY CHECK (id = 1), knowledge_base_id text NOT NULL,
151+
phase text NOT NULL CHECK (phase IN ('documents', 'embeddings', 'done')),
152+
after_id text NOT NULL, reindexed_through text NOT NULL DEFAULT '',
153+
vacuumed_tables integer NOT NULL DEFAULT 0)`
154+
await sql`INSERT INTO search_embedding_cleanup_progress
155+
VALUES (1, 'search', ${phase}, ${phase === 'documents' ? 'search-doc' : '00050'}, 'legacy_hnsw_idx', 6)`
156+
await sql`CREATE INDEX legacy_hnsw_idx ON embedding_search USING hnsw (vector public.vector_l2_ops)`
157+
const [original] = await sql`SELECT to_regclass('legacy_hnsw_idx')::oid AS oid`
158+
await sql`CREATE TABLE script_migrations (name text PRIMARY KEY, applied_at timestamptz DEFAULT now())`
159+
if (phase === 'embeddings') {
160+
await sql`DELETE FROM embedding WHERE knowledge_base_id = 'search' AND id <= '00050'`
161+
}
162+
if (phase === 'done') {
163+
await sql`DELETE FROM embedding WHERE knowledge_base_id = 'search'`
164+
await sql`INSERT INTO script_migrations (name)
165+
VALUES ('0027_retire_search_embeddings'), ('0028_maintain_search_retirement')`
166+
}
167+
if (legacy === 'deleted') {
168+
await sql`DELETE FROM document WHERE knowledge_base_id = 'search'`
169+
await sql`DELETE FROM knowledge_base WHERE id = 'search'`
170+
} else if (legacy === 'ordinary') {
171+
await sql`UPDATE knowledge_base SET is_search_index = false WHERE id = 'search'`
172+
await sql`UPDATE document SET user_excluded = false, enabled = true,
173+
processing_queue_token = 'keep-dispatch' WHERE id = 'search-doc'`
174+
await sql`INSERT INTO embedding VALUES ('new-ordinary-chunk', 'search', 'search-doc')`
175+
await sql`INSERT INTO embedding_search (id) VALUES ('new-ordinary-chunk')`
176+
}
177+
const migrations = scriptMigrations.filter((migration) =>
178+
[
179+
'0027_retire_search_embeddings',
180+
'0028_maintain_search_retirement',
181+
'0029_retire_all_search_embeddings',
182+
].includes(migration.name)
183+
)
184+
try {
185+
await runScriptMigrations(sql, migrations)
186+
const preserved = legacy === 'ordinary' ? 502 : 501
187+
expect((await sql`SELECT count(*)::int AS n FROM embedding`)[0].n).toBe(preserved)
188+
expect((await sql`SELECT count(*)::int AS n FROM embedding_search`)[0].n).toBe(preserved)
189+
if (otherSearch) {
190+
expect(
191+
(await sql`SELECT user_excluded, enabled FROM document WHERE id = 'aaa-second-doc'`)[0]
192+
).toEqual({ user_excluded: true, enabled: false })
193+
}
194+
if (legacy === 'ordinary') {
195+
expect(
196+
(
197+
await sql`SELECT user_excluded, enabled, processing_queue_token FROM document WHERE id = 'search-doc'`
198+
)[0]
199+
).toEqual({
200+
user_excluded: false,
201+
enabled: true,
202+
processing_queue_token: 'keep-dispatch',
203+
})
204+
}
205+
const [rebuilt] = await sql`SELECT to_regclass('legacy_hnsw_idx')::oid AS oid`
206+
if (otherSearch) expect(rebuilt.oid).not.toBe(original.oid)
207+
else expect(rebuilt.oid).toBe(original.oid)
208+
expect(
209+
await sql`SELECT name FROM script_migrations WHERE name = '0029_retire_all_search_embeddings'`
210+
).toHaveLength(1)
211+
await runScriptMigrations(sql, migrations)
212+
expect((await sql`SELECT to_regclass('legacy_hnsw_idx')::oid AS oid`)[0].oid).toBe(
213+
rebuilt.oid
214+
)
215+
} finally {
216+
await sql`DROP INDEX legacy_hnsw_idx`
217+
}
218+
}
219+
)
220+
221+
it.each(['embeddings', 'done'] as const)(
222+
'rejects changed markers outside the current page when resuming %s',
223+
async (phase) => {
224+
await sql`INSERT INTO knowledge_base VALUES ('empty-search', true)`
225+
if (phase === 'done') {
226+
await pass()
227+
await sql`DELETE FROM script_migrations`
228+
await sql`UPDATE knowledge_base SET is_search_index = false WHERE id = 'empty-search'`
229+
}
230+
await sql`CREATE FUNCTION change_empty_search_marker() RETURNS trigger LANGUAGE plpgsql AS $$
231+
BEGIN
232+
UPDATE knowledge_base SET is_search_index = false WHERE id = 'empty-search';
233+
RETURN NULL;
234+
END $$`
235+
await sql`CREATE TRIGGER change_empty_search_marker AFTER DELETE ON embedding
236+
FOR EACH STATEMENT EXECUTE FUNCTION change_empty_search_marker()`
237+
try {
238+
await expect(pass()).rejects.toThrow('no longer a Search knowledge base')
239+
expect(await sql`SELECT name FROM script_migrations`).toHaveLength(0)
240+
expect((await sql`SELECT count(*)::int AS n FROM embedding`)[0].n).toBe(501)
241+
await sql`DROP TRIGGER change_empty_search_marker ON embedding`
242+
await sql`UPDATE knowledge_base SET is_search_index = true WHERE id = 'empty-search'`
243+
expect(await pass()).toBe(true)
244+
expect((await sql`SELECT count(*)::int AS n FROM embedding`)[0].n).toBe(501)
245+
} finally {
246+
await sql`DROP TRIGGER IF EXISTS change_empty_search_marker ON embedding`
247+
await sql`DROP FUNCTION change_empty_search_marker()`
248+
}
249+
}
250+
)
251+
252+
it('rolls back failed pages, rechecks every target marker, and resumes the frozen scope', async () => {
116253
await sql`INSERT INTO embedding
117254
SELECT lpad(i::text, 5, '0'), 'search', 'search-doc' FROM generate_series(1003, 26002) i`
255+
await sql`INSERT INTO knowledge_base VALUES ('second-search', true)`
256+
await sql`INSERT INTO document (id, knowledge_base_id) VALUES ('second-search-doc', 'second-search')`
257+
await sql`INSERT INTO embedding VALUES ('26003', 'second-search', 'second-search-doc')`
118258
await sql`CREATE TABLE deletion_blocker (id text REFERENCES embedding(id))`
119259
await sql`INSERT INTO deletion_blocker VALUES ('26002')`
120260
await expect(pass()).rejects.toThrow()
@@ -123,6 +263,10 @@ describe('retiring dormant Search embeddings', () => {
123263
expect(remaining.n).toBeGreaterThan(501)
124264
expect(remaining.n).toBeLessThan(26002)
125265
expect(await sql`SELECT name FROM script_migrations`).toHaveLength(0)
266+
await sql`UPDATE knowledge_base SET is_search_index = false WHERE id = 'second-search'`
267+
await expect(pass()).rejects.toThrow('no longer a Search knowledge base')
268+
expect((await sql`SELECT count(*)::int AS n FROM embedding`)[0].n).toBe(remaining.n)
269+
await sql`UPDATE knowledge_base SET is_search_index = true WHERE id = 'second-search'`
126270
await expect(pass()).rejects.toThrow()
127271
expect(await sql`SELECT * FROM search_embedding_cleanup_progress`).toEqual(before)
128272
expect((await sql`SELECT count(*)::int AS n FROM embedding`)[0].n).toBe(remaining.n)

0 commit comments

Comments
 (0)