@@ -43,7 +43,6 @@ vi.mock('@trigger.dev/sdk', async (importOriginal) => {
4343 const original = await importOriginal < typeof import ( '@trigger.dev/sdk' ) > ( )
4444 return {
4545 ...original ,
46- runs : { list : fixture . listRuns } ,
4746 tasks : { ...original . tasks , batchTrigger : fixture . batchTrigger } ,
4847 }
4948} )
@@ -96,6 +95,7 @@ import {
9695 retryDocumentProcessing ,
9796} from '@/lib/knowledge/documents/service'
9897import { MAX_PROCESSING_ATTEMPTS , QUEUED_DISPATCH_GRACE_MS } from '@/lib/knowledge/documents/types'
98+ import type { SyncResult } from '@/connectors/types'
9999
100100const fixtures : ReturnType < typeof createKnowledgeAclFixtureIds > [ ] = [ ]
101101const old = ( ) => new Date ( Date . now ( ) - QUEUED_DISPATCH_GRACE_MS - 60_000 )
@@ -179,7 +179,110 @@ afterAll(async () => {
179179 await db . $client . end ( )
180180} )
181181
182+ async function recoverFixture (
183+ ids : ReturnType < typeof createKnowledgeAclFixtureIds > ,
184+ mode : 'independent' | 'connector'
185+ ) {
186+ if ( mode === 'independent' ) return recoverKnowledgeDocumentProcessing ( )
187+ const result : SyncResult = {
188+ docsAdded : 0 ,
189+ docsUpdated : 0 ,
190+ docsDeleted : 0 ,
191+ docsUnchanged : 0 ,
192+ docsSkipped : 0 ,
193+ docsFailed : 0 ,
194+ processingDispatch : { requested : 0 , accepted : 0 , failed : 0 } ,
195+ }
196+ await sweepStuckDocuments ( {
197+ connectorId : ids . connectorId ,
198+ knowledgeBaseId : ids . knowledgeBaseId ,
199+ syncStartedAt : new Date ( ) ,
200+ retryCutoff : new Date ( Date . now ( ) - 7 * 24 * 60 * 60_000 ) ,
201+ billingAttribution : await resolveSystemBillingAttribution ( ids . workspaceId ) ,
202+ result,
203+ lease : createContentSyncLease ( ids . connectorId , ids . lockId ) ,
204+ } )
205+ return result . processingDispatch . requested
206+ }
207+
182208describe ( 'independent recovery of retained connector documents' , ( ) => {
209+ it . each ( [ 'independent' , 'connector' ] as const ) (
210+ '%s recovery preserves a job queued beyond the grace period' ,
211+ async ( mode ) => {
212+ const ids = await seed ( )
213+ const file = await failedFile ( ids )
214+ await db
215+ . update ( document )
216+ . set ( { processingStatus : 'pending' } )
217+ . where ( eq ( document . id , file . documentId ) )
218+ fixture . useTrigger = true
219+ fixture . listRuns . mockResolvedValue ( {
220+ data : [ { id : 'run-queued' , status : 'QUEUED' } ] ,
221+ hasNextPage : ( ) => false ,
222+ } )
223+ expect ( await recoverFixture ( ids , mode ) ) . toBe ( 0 )
224+ const [ row ] = await db . select ( ) . from ( document ) . where ( eq ( document . id , file . documentId ) )
225+ expect ( row . processingAttempts ) . toBe ( 1 )
226+ expect ( row . processingQueueToken ) . toBe ( 'old-fixture-generation' )
227+ expect ( row . processingRecoveryAfter ) . not . toBeNull ( )
228+ expect ( await eventsFor ( ids ) ) . toHaveLength ( 0 )
229+ await db
230+ . update ( knowledgeConnector )
231+ . set ( { status : 'paused' } )
232+ . where ( eq ( knowledgeConnector . id , ids . connectorId ) )
233+ }
234+ )
235+
236+ it . each ( [ 'independent' , 'connector' ] as const ) (
237+ '%s recovery rechecks the generation after its remote lookup' ,
238+ async ( mode ) => {
239+ const ids = await seed ( )
240+ const file = await failedFile ( ids )
241+ fixture . useTrigger = true
242+ fixture . listRuns . mockImplementation ( async ( ) => {
243+ await db
244+ . update ( document )
245+ . set ( { processingQueueToken : 'replacement-generation' } )
246+ . where ( eq ( document . id , file . documentId ) )
247+ return { data : [ ] , hasNextPage : ( ) => false }
248+ } )
249+ expect ( await recoverFixture ( ids , mode ) ) . toBe ( 0 )
250+ const [ row ] = await db . select ( ) . from ( document ) . where ( eq ( document . id , file . documentId ) )
251+ expect ( row . processingAttempts ) . toBe ( 1 )
252+ expect ( row . processingQueueToken ) . toBe ( 'replacement-generation' )
253+ expect ( await eventsFor ( ids ) ) . toHaveLength ( 0 )
254+ await db
255+ . update ( knowledgeConnector )
256+ . set ( { status : 'paused' } )
257+ . where ( eq ( knowledgeConnector . id , ids . connectorId ) )
258+ }
259+ )
260+
261+ it . each ( [ 'pending' , 'processing' ] ) (
262+ 'does not replace an aged %s outbox continuation' ,
263+ async ( status ) => {
264+ const ids = await seed ( )
265+ const file = await failedFile ( ids )
266+ const token = generateId ( )
267+ await db
268+ . update ( document )
269+ . set ( { processingQueueToken : token } )
270+ . where ( eq ( document . id , file . documentId ) )
271+ await db . insert ( outboxEvent ) . values ( {
272+ id : token ,
273+ eventType : 'knowledge.document.processing.resume' ,
274+ payload : { knowledgeBaseId : ids . knowledgeBaseId , documentId : file . documentId } ,
275+ status,
276+ availableAt : old ( ) ,
277+ } )
278+ expect ( await recoverFixture ( ids , 'independent' ) ) . toBe ( 0 )
279+ expect ( await recoverFixture ( ids , 'connector' ) ) . toBe ( 0 )
280+ const [ row ] = await db . select ( ) . from ( document ) . where ( eq ( document . id , file . documentId ) )
281+ expect ( row . processingAttempts ) . toBe ( 1 )
282+ expect ( row . processingQueueToken ) . toBe ( token )
283+ }
284+ )
285+
183286 it . each ( [ 'manual' , 'redelivery' ] ) (
184287 'respects a concurrent liveness cooldown before %s replacement' ,
185288 async ( path ) => {
0 commit comments