11import { retireSearchEmbeddingsMigration } from '@sim/db/script-migrations/0027_retire_search_embeddings'
22import { 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'
44import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
55import { sleep } from '@sim/utils/helpers'
66import { 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