11/** Real PostgreSQL cancellation must roll back preparation and release its advisory lock. */
22import { withUtcTimestamps } from '@sim/db/timestamps'
33import { getPostgresErrorCode } from '@sim/utils/errors'
4+ import { sleep } from '@sim/utils/helpers'
45import { generateId } from '@sim/utils/id'
56import { drizzle , type PostgresJsDatabase } from 'drizzle-orm/postgres-js'
67import postgres from 'postgres'
7- import { afterAll , beforeAll , describe , expect , it , vi } from 'vitest'
8+ import { afterAll , beforeAll , beforeEach , describe , expect , it , vi } from 'vitest'
89
910const database = vi . hoisted ( ( ) => ( { current : undefined as PostgresJsDatabase | undefined } ) )
11+ const mocks = vi . hoisted ( ( ) => ( { batchTrigger : vi . fn ( ) } ) )
1012vi . mock ( '@sim/db' , ( ) => ( {
1113 get db ( ) {
1214 if ( ! database . current ) throw new Error ( 'Dispatcher test database is not initialized' )
@@ -17,8 +19,14 @@ vi.mock('@/lib/workspace-files/search/indexing', () => ({
1719 indexWorkspaceFileForSearch : vi . fn ( ) ,
1820 markWorkspaceFileSearchIndexFailed : vi . fn ( ) ,
1921} ) )
22+ vi . mock ( '@trigger.dev/sdk' , ( ) => ( { tasks : { batchTrigger : mocks . batchTrigger } } ) )
23+ vi . mock ( '@/lib/core/config/env-flags' , ( ) => ( { isTriggerDevEnabled : true } ) )
24+ vi . mock ( '@/lib/core/async-jobs/region' , ( ) => ( { resolveTriggerRegion : async ( ) => 'us-east-1' } ) )
2025
21- import { prepareWorkspaceFileSearchDispatch } from '@/lib/workspace-files/search/dispatcher'
26+ import {
27+ dispatchWorkspaceFileSearchIndexJobs ,
28+ prepareWorkspaceFileSearchDispatch ,
29+ } from '@/lib/workspace-files/search/dispatcher'
2230
2331describe ( 'workspace file search dispatch PostgreSQL deadlines' , ( ) => {
2432 const schemaName = `dispatch_test_${ generateId ( ) . replaceAll ( '-' , '' ) } `
@@ -41,11 +49,32 @@ describe('workspace file search dispatch PostgreSQL deadlines', () => {
4149 id text PRIMARY KEY, after_workspace_id text, after_file_id text,
4250 completed_at timestamp, updated_at timestamp NOT NULL
4351 )`
52+ await connection `CREATE TABLE workspace_files (
53+ id text PRIMARY KEY, workspace_id text NOT NULL, context text NOT NULL,
54+ deleted_at timestamp, content_updated_at timestamp NOT NULL
55+ )`
56+ await connection `CREATE TABLE workspace_file_search_index (
57+ file_id text NOT NULL, workspace_id text NOT NULL, source_content_updated_at timestamp NOT NULL,
58+ status text NOT NULL, dispatched_at timestamp, updated_at timestamp NOT NULL,
59+ PRIMARY KEY (file_id, source_content_updated_at)
60+ )`
61+ await connection `CREATE TABLE workspace_file_search_dispatch_queue (
62+ workspace_id text PRIMARY KEY, enqueued_at timestamp NOT NULL,
63+ updated_at timestamp NOT NULL, last_dispatched_at timestamp
64+ )`
4465 await connection `INSERT INTO workspace_file_search_backfill (id, updated_at)
4566 VALUES ('workspace-file-search-v1', '2026-09-16 00:00:00')`
4667 database . current = drizzle ( connection )
4768 } )
4869
70+ beforeEach ( async ( ) => {
71+ mocks . batchTrigger . mockReset ( )
72+ await connection `DROP TRIGGER IF EXISTS slow_backfill ON workspace_file_search_backfill`
73+ await connection `TRUNCATE workspace_files, workspace_file_search_index, workspace_file_search_dispatch_queue`
74+ await connection `UPDATE workspace_file_search_backfill
75+ SET updated_at = '2026-09-16 00:00:00', completed_at = NULL`
76+ } )
77+
4978 afterAll ( async ( ) => {
5079 try {
5180 await connection `DROP SCHEMA ${ connection ( schemaName ) } CASCADE`
@@ -108,4 +137,55 @@ describe('workspace file search dispatch PostgreSQL deadlines', () => {
108137 expect ( row . updated_at ) . toBe ( '2026-09-16 00:00:00' )
109138 await expectAdvisoryLockReleased ( )
110139 } , 20_000 )
140+
141+ it ( 'releases committed claims after contention exceeds the preparation lock deadline' , async ( ) => {
142+ const fileId = generateId ( )
143+ const workspaceId = generateId ( )
144+ await connection `UPDATE workspace_file_search_backfill SET completed_at = now()`
145+ await connection `INSERT INTO workspace_files (id, workspace_id, context, content_updated_at)
146+ VALUES (${ fileId } , ${ workspaceId } , 'workspace', '2026-09-16')`
147+ await connection `INSERT INTO workspace_file_search_index
148+ (file_id, workspace_id, source_content_updated_at, status, updated_at)
149+ VALUES (${ fileId } , ${ workspaceId } , '2026-09-16', 'pending', now())`
150+ await connection `INSERT INTO workspace_file_search_dispatch_queue
151+ (workspace_id, enqueued_at, updated_at) VALUES (${ workspaceId } , now(), now())`
152+
153+ const enqueueError = new Error ( 'Queue unavailable' )
154+ let blocker : Promise < unknown > | undefined
155+ mocks . batchTrigger . mockImplementationOnce ( async ( ) => {
156+ let locked = ( ) => { }
157+ const lockReady = new Promise < void > ( ( resolve ) => {
158+ locked = resolve
159+ } )
160+ blocker = connection . begin ( async ( tx ) => {
161+ await tx `SELECT file_id FROM workspace_file_search_index WHERE file_id = ${ fileId } FOR UPDATE`
162+ locked ( )
163+ await sleep ( 3_000 )
164+ } )
165+ await Promise . race ( [ lockReady , blocker ] )
166+ throw enqueueError
167+ } )
168+
169+ try {
170+ await expect ( dispatchWorkspaceFileSearchIndexJobs ( ) ) . rejects . toBe ( enqueueError )
171+ expect ( mocks . batchTrigger ) . toHaveBeenCalledWith ( 'workspace-file-search-index' , [
172+ expect . objectContaining ( {
173+ payload : {
174+ fileId,
175+ workspaceId,
176+ sourceContentUpdatedAt : '2026-09-16T00:00:00.000Z' ,
177+ } ,
178+ } ) ,
179+ ] )
180+ const [ index ] = await connection `SELECT dispatched_at FROM workspace_file_search_index
181+ WHERE file_id = ${ fileId } `
182+ expect ( index . dispatched_at ) . toBeNull ( )
183+ const [ queued ] =
184+ await connection `SELECT workspace_id FROM workspace_file_search_dispatch_queue
185+ WHERE workspace_id = ${ workspaceId } `
186+ expect ( queued . workspace_id ) . toBe ( workspaceId )
187+ } finally {
188+ await blocker
189+ }
190+ } )
111191} )
0 commit comments