@@ -89,7 +89,8 @@ describe('workspace file search dispatch PostgreSQL deadlines', () => {
8989 await connection `CREATE INDEX workspace_files_workspace_active_keyset_idx
9090 ON workspace_files (workspace_id, id)
9191 WHERE deleted_at IS NULL AND context = 'workspace' AND workspace_id IS NOT NULL`
92- await connection `CREATE INDEX ON workspace_file_search_revision
92+ await connection `CREATE INDEX workspace_file_search_revision_pending_idx
93+ ON workspace_file_search_revision
9394 (workspace_id, updated_at, file_id, source_content_updated_at)
9495 WHERE status = 'pending' AND dispatched_at IS NULL`
9596 await connection `CREATE INDEX ON workspace_file_search_revision (workspace_id, dispatched_at)
@@ -263,6 +264,39 @@ describe('workspace file search dispatch PostgreSQL deadlines', () => {
263264 expect ( plan ) . toMatch ( / I n d e x C o n d : .* R O W \( / )
264265 } , 30_000 )
265266
267+ it ( 'claims from the ordered pending index rather than a hash join over the backlog' , async ( ) => {
268+ await seedQueue ( 'workspace-1' , 10_000 )
269+ await connection `ANALYZE workspace_files`
270+ await connection `ANALYZE workspace_file_search_revision`
271+
272+ statements . length = 0
273+ await prepareWorkspaceFileSearchDispatch ( )
274+
275+ const claim = statements . find ( ( statement ) =>
276+ statement . query . includes ( 'FOR UPDATE OF search_index SKIP LOCKED' )
277+ )
278+ expect ( claim ) . toBeDefined ( )
279+
280+ const plan = await connection . begin ( async ( tx ) => {
281+ /**
282+ * At fixture scale the planner already nests the join, so it is pinned to the choice
283+ * production makes when it misestimates the timestamp equi-join under `FOR UPDATE`. A join
284+ * spelling then hashes every file and every pending revision of the workspace and sorts the
285+ * whole backlog before the top-N cut; the correlated LATERAL cannot be flattened into that
286+ * join, so the claim stays an ordered walk of the pending index.
287+ */
288+ await tx `SET LOCAL enable_nestloop = off`
289+ const rows = await tx . unsafe ( `EXPLAIN ${ claim ?. query } ` , claim ?. params as never [ ] )
290+ return rows . map ( ( row : Record < string , unknown > ) => row [ 'QUERY PLAN' ] ) . join ( '\n' )
291+ } )
292+
293+ /** The locked candidate scan must be fed by the ordered index walk, not a sorted hash join. */
294+ expect ( plan ) . toMatch (
295+ / L o c k R o w s [ ^ \n ] * \n \s * - > { 2 } N e s t e d L o o p [ ^ \n ] * \n \s * - > { 2 } I n d e x S c a n 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 _ p e n d i n g _ i d x /
296+ )
297+ expect ( plan ) . not . toMatch ( / S o r t K e y : s e a r c h _ i n d e x ( _ \d + ) ? \. u p d a t e d _ a t / )
298+ } , 30_000 )
299+
266300 it ( 'fails on a locked backfill row and releases the dispatcher lock' , async ( ) => {
267301 let release = ( ) => { }
268302 let locked = ( ) => { }
0 commit comments