@@ -18,7 +18,8 @@ vi.mock('@/lib/workspace-files/search/indexing', () => ({
1818 indexWorkspaceFileForSearch : vi . fn ( ) ,
1919 markWorkspaceFileSearchIndexFailed : vi . fn ( ) ,
2020} ) )
21- vi . mock ( '@/lib/workspace-files/search/index-state' , ( ) => ( {
21+ vi . mock ( '@/lib/workspace-files/search/index-state' , async ( importOriginal ) => ( {
22+ ...( await importOriginal < typeof import ( '@/lib/workspace-files/search/index-state' ) > ( ) ) ,
2223 cleanupFileSearchBuilds : vi . fn ( ) . mockResolvedValue ( 0 ) ,
2324} ) )
2425vi . mock ( '@trigger.dev/sdk' , ( ) => ( { tasks : { batchTrigger : mocks . batchTrigger } } ) )
@@ -30,9 +31,11 @@ import {
3031 FILE_SEARCH_BACKFILL_PAGE_SIZE ,
3132 FILE_SEARCH_DISPATCH_HANDOFF_MS ,
3233 FILE_SEARCH_INDEX_STALE_DISPATCH_MS ,
34+ FILE_SEARCH_RECONCILE_INTERVAL_MS ,
3335} from '@/lib/workspace-files/search/constants'
3436import {
3537 dispatchWorkspaceFileSearchIndexJobs ,
38+ hasWorkspaceFileSearchDispatchWork ,
3639 prepareWorkspaceFileSearchDispatch ,
3740} from '@/lib/workspace-files/search/dispatcher'
3841
@@ -92,19 +95,21 @@ describe('workspace file search dispatch PostgreSQL deadlines', () => {
9295 WHERE status = 'pending' AND dispatched_at IS NULL`
9396 await connection `CREATE INDEX ON workspace_file_search_revision (workspace_id, dispatched_at)
9497 WHERE status = 'pending' AND dispatched_at IS NOT NULL`
95- await connection `INSERT INTO workspace_file_search_backfill (id, updated_at)
96- VALUES ('workspace-file-search-chunks-v2', '2026-09-16 00:00:00')`
9798 await connection `CREATE TABLE workspace_file_search_build (id text PRIMARY KEY, expires_at timestamp)`
99+ await connection `CREATE INDEX workspace_file_search_build_cleanup_idx
100+ ON workspace_file_search_build (expires_at, id) WHERE expires_at IS NOT NULL`
98101 await connection `CREATE TABLE workspace_file_search_chunk (build_id text NOT NULL, ordinal integer NOT NULL, PRIMARY KEY(build_id, ordinal))`
99102 database . current = drizzle ( connection )
100103 } )
101104
102105 beforeEach ( async ( ) => {
103106 mocks . batchTrigger . mockReset ( )
104107 await connection `DROP TRIGGER IF EXISTS slow_backfill ON workspace_file_search_backfill`
105- await connection `TRUNCATE workspace_files, workspace_file_search_revision, workspace_file_search_dispatch_queue`
106- await connection `UPDATE workspace_file_search_backfill
107- SET updated_at = '2026-09-16 00:00:00', completed_at = NULL,
108+ await connection `TRUNCATE workspace_files, workspace_file_search_revision,
109+ workspace_file_search_dispatch_queue, workspace_file_search_build`
110+ await connection `INSERT INTO workspace_file_search_backfill (id, updated_at)
111+ VALUES ('workspace-file-search-chunks-v2', '2026-09-16 00:00:00')
112+ ON CONFLICT (id) DO UPDATE SET updated_at = EXCLUDED.updated_at, completed_at = NULL,
108113 after_workspace_id = NULL, after_file_id = NULL`
109114 } )
110115
@@ -544,4 +549,100 @@ describe('workspace file search dispatch PostgreSQL deadlines', () => {
544549 { lock_timeout : '0' , statement_timeout : '0' , transaction_timeout : '0' } ,
545550 ] )
546551 } )
552+
553+ describe ( 'dispatch work check' , ( ) => {
554+ const reconcileSeconds = FILE_SEARCH_RECONCILE_INTERVAL_MS / 1000
555+ const staleSeconds = FILE_SEARCH_INDEX_STALE_DISPATCH_MS / 1000
556+
557+ /**
558+ * Everything a deployment between file changes holds: a reconciled backfill, a published
559+ * revision, a claim whose run is under way, a live build, and a build still inside its lease.
560+ */
561+ async function seedIdleDeployment ( ) {
562+ await connection `UPDATE workspace_file_search_backfill
563+ SET completed_at = now() - make_interval(secs => ${ reconcileSeconds - 60 } )`
564+ await connection `INSERT INTO workspace_files (id, workspace_id, context, content_updated_at)
565+ VALUES ('ready-file', 'workspace-1', 'workspace', '2026-09-16'),
566+ ('running-file', 'workspace-1', 'workspace', '2026-09-16')`
567+ await connection `INSERT INTO workspace_file_search_revision
568+ (file_id, workspace_id, source_content_updated_at, status, dispatched_at, handoff_expires_at)
569+ VALUES ('ready-file', 'workspace-1', '2026-09-16', 'ready', NULL, NULL),
570+ ('running-file', 'workspace-1', '2026-09-16', 'pending',
571+ now() - make_interval(secs => ${ staleSeconds - 60 } ), clock_timestamp() + interval '1 minute')`
572+ await connection `INSERT INTO workspace_file_search_build (id, expires_at)
573+ VALUES ('published-build', NULL), ('leased-build', now() + interval '1 minute')`
574+ }
575+
576+ it ( 'reports no work for an idle deployment, on which a dispatch pass does nothing' , async ( ) => {
577+ await seedIdleDeployment ( )
578+
579+ await expect ( hasWorkspaceFileSearchDispatchWork ( new Date ( ) ) ) . resolves . toBe ( false )
580+ await expect ( prepareWorkspaceFileSearchDispatch ( ) ) . resolves . toMatchObject ( {
581+ payloads : [ ] ,
582+ backfilledFiles : 0 ,
583+ reapedClaims : 0 ,
584+ } )
585+ } )
586+
587+ it . each ( [
588+ {
589+ work : 'a backfill pass that never completed' ,
590+ seed : ( ) => connection `UPDATE workspace_file_search_backfill SET completed_at = NULL` ,
591+ } ,
592+ {
593+ work : 'a missing backfill cursor' ,
594+ seed : ( ) => connection `DELETE FROM workspace_file_search_backfill` ,
595+ } ,
596+ {
597+ work : 'a backfill reconcile that is due' ,
598+ seed : ( ) => connection `UPDATE workspace_file_search_backfill
599+ SET completed_at = now() - make_interval(secs => ${ reconcileSeconds } )` ,
600+ } ,
601+ {
602+ work : 'a queued workspace' ,
603+ seed : ( ) => connection `INSERT INTO workspace_file_search_dispatch_queue
604+ (workspace_id, enqueued_at, updated_at) VALUES ('workspace-1', now(), now())` ,
605+ } ,
606+ {
607+ work : 'an expired build' ,
608+ seed : ( ) => connection `UPDATE workspace_file_search_build SET expires_at = now()
609+ WHERE id = 'leased-build'` ,
610+ } ,
611+ {
612+ work : 'a claim past the stale-dispatch window' ,
613+ seed : ( ) => connection `UPDATE workspace_file_search_revision
614+ SET dispatched_at = now() - make_interval(secs => ${ staleSeconds + 60 } )
615+ WHERE file_id = 'running-file'` ,
616+ } ,
617+ {
618+ work : 'a claim past its handoff deadline' ,
619+ seed : ( ) => connection `UPDATE workspace_file_search_revision
620+ SET handoff_expires_at = clock_timestamp() - interval '1 millisecond'
621+ WHERE file_id = 'running-file'` ,
622+ } ,
623+ ] ) ( 'reports work for $work' , async ( { seed } ) => {
624+ await seedIdleDeployment ( )
625+ await seed ( )
626+
627+ await expect ( hasWorkspaceFileSearchDispatchWork ( new Date ( ) ) ) . resolves . toBe ( true )
628+ } )
629+
630+ it ( 'probes the cleanup and active-claim indexes rather than scanning' , async ( ) => {
631+ await seedIdleDeployment ( )
632+ statements . length = 0
633+ await expect ( hasWorkspaceFileSearchDispatchWork ( new Date ( ) ) ) . resolves . toBe ( false )
634+
635+ const probe = statements . find ( ( statement ) => statement . query . includes ( '"hasWork"' ) )
636+ expect ( probe ) . toBeDefined ( )
637+ const plan = await connection . begin ( async ( tx ) => {
638+ await tx `SET LOCAL enable_seqscan = off`
639+ const rows = await tx . unsafe ( `EXPLAIN ${ probe ?. query } ` , probe ?. params as never [ ] )
640+ return rows . map ( ( row : Record < string , unknown > ) => row [ 'QUERY PLAN' ] ) . join ( '\n' )
641+ } )
642+
643+ expect ( plan ) . toContain ( 'workspace_file_search_build_cleanup_idx' )
644+ expect ( plan ) . toMatch ( / u s i n g w o r k s p a c e _ f i l e _ s e a r c h _ r e v i s i o n _ w o r k s p a c e _ i d _ d i s p a t c h e d _ a t _ i d x / )
645+ expect ( plan ) . not . toMatch ( / S e q S c a n / )
646+ } )
647+ } )
547648} )
0 commit comments