@@ -17,9 +17,21 @@ import {
1717} from '@sim/db/schema'
1818import { generateId } from '@sim/utils/id'
1919import { and , eq , inArray , sql } from 'drizzle-orm'
20- import { afterAll , beforeAll , describe , expect , it , vi } from 'vitest'
20+ import { afterAll , afterEach , beforeAll , describe , expect , it , vi } from 'vitest'
2121
22- const fixture = vi . hoisted ( ( ) => ( { root : '' , embeddingCalls : 0 } ) )
22+ const fixture = vi . hoisted ( ( ) => ( {
23+ root : '' ,
24+ embeddingCalls : 0 ,
25+ queueEnabled : false ,
26+ listRuns : vi . fn ( ) ,
27+ } ) )
28+ vi . mock ( '@/lib/core/config/trigger-runtime' , ( ) => ( {
29+ isInsideTriggerRun : ( ) => fixture . queueEnabled ,
30+ } ) )
31+ vi . mock ( '@trigger.dev/sdk' , async ( original ) => ( {
32+ ...( await original < typeof import ( '@trigger.dev/sdk' ) > ( ) ) ,
33+ runs : { list : fixture . listRuns } ,
34+ } ) )
2335vi . mock ( '@/lib/uploads/core/setup.server' , ( ) => ( {
2436 get UPLOAD_DIR_SERVER ( ) {
2537 return fixture . root
@@ -52,6 +64,7 @@ import { searchKnowledge } from '@/lib/knowledge/application/search'
5264import { searchScopedKnowledge } from '@/lib/knowledge/application/workspace-search'
5365import { createContentSyncLease } from '@/lib/knowledge/connectors/sync-lock'
5466import { addDocument } from '@/lib/knowledge/connectors/sync-persistence'
67+ import { sweepStuckDocuments } from '@/lib/knowledge/connectors/sync-primitives'
5568import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/documents/processing-outbox-handler'
5669import {
5770 DOCUMENT_RECOVERY_BATCH_SIZE ,
@@ -60,6 +73,7 @@ import {
6073} from '@/lib/knowledge/documents/processing-recovery'
6174import { processDocumentAsync } from '@/lib/knowledge/documents/service'
6275import { MAX_PROCESSING_ATTEMPTS , QUEUED_DISPATCH_GRACE_MS } from '@/lib/knowledge/documents/types'
76+ import type { SyncResult } from '@/connectors/types'
6377
6478const fixtures : ReturnType < typeof createKnowledgeAclFixtureIds > [ ] = [ ]
6579const old = ( ) => new Date ( Date . now ( ) - QUEUED_DISPATCH_GRACE_MS - 60_000 )
@@ -119,6 +133,11 @@ async function failedFile(
119133 return file
120134}
121135
136+ afterEach ( ( ) => {
137+ fixture . queueEnabled = false
138+ fixture . listRuns . mockReset ( )
139+ } )
140+
122141beforeAll ( ( ) => {
123142 fixture . root = mkdtempSync ( path . join ( tmpdir ( ) , 'sim-stored-recovery-' ) )
124143} )
@@ -137,7 +156,107 @@ afterAll(async () => {
137156 await db . $client . end ( )
138157} )
139158
159+ async function recoverFixture (
160+ ids : ReturnType < typeof createKnowledgeAclFixtureIds > ,
161+ mode : 'independent' | 'connector'
162+ ) {
163+ if ( mode === 'independent' ) return recoverKnowledgeDocumentProcessing ( )
164+ const result : SyncResult = {
165+ docsAdded : 0 ,
166+ docsUpdated : 0 ,
167+ docsDeleted : 0 ,
168+ docsUnchanged : 0 ,
169+ docsSkipped : 0 ,
170+ docsFailed : 0 ,
171+ processingDispatch : { requested : 0 , accepted : 0 , failed : 0 } ,
172+ }
173+ await sweepStuckDocuments ( {
174+ connectorId : ids . connectorId ,
175+ knowledgeBaseId : ids . knowledgeBaseId ,
176+ syncStartedAt : new Date ( ) ,
177+ retryCutoff : new Date ( Date . now ( ) - 7 * 24 * 60 * 60_000 ) ,
178+ billingAttribution : await resolveSystemBillingAttribution ( ids . workspaceId ) ,
179+ result,
180+ lease : createContentSyncLease ( ids . connectorId , ids . lockId ) ,
181+ } )
182+ return result . processingDispatch . requested
183+ }
184+
140185describe ( 'independent recovery of retained connector documents' , ( ) => {
186+ it . each ( [ 'independent' , 'connector' ] as const ) (
187+ '%s recovery preserves a job queued beyond the grace period' ,
188+ async ( mode ) => {
189+ const ids = await seed ( )
190+ const file = await failedFile ( ids )
191+ await db
192+ . update ( document )
193+ . set ( { processingStatus : 'pending' } )
194+ . where ( eq ( document . id , file . documentId ) )
195+ fixture . queueEnabled = true
196+ fixture . listRuns . mockResolvedValue ( { data : [ { id : 'run-queued' , status : 'QUEUED' } ] } )
197+ expect ( await recoverFixture ( ids , mode ) ) . toBe ( 0 )
198+ const [ row ] = await db . select ( ) . from ( document ) . where ( eq ( document . id , file . documentId ) )
199+ expect ( row . processingAttempts ) . toBe ( 1 )
200+ expect ( row . processingQueueToken ) . toBe ( 'old-fixture-generation' )
201+ expect ( row . processingRecoveryAfter ) . not . toBeNull ( )
202+ expect ( await eventsFor ( ids ) ) . toHaveLength ( 0 )
203+ await db
204+ . update ( knowledgeConnector )
205+ . set ( { status : 'paused' } )
206+ . where ( eq ( knowledgeConnector . id , ids . connectorId ) )
207+ }
208+ )
209+
210+ it . each ( [ 'independent' , 'connector' ] as const ) (
211+ '%s recovery rechecks the generation after its remote lookup' ,
212+ async ( mode ) => {
213+ const ids = await seed ( )
214+ const file = await failedFile ( ids )
215+ fixture . queueEnabled = true
216+ fixture . listRuns . mockImplementation ( async ( ) => {
217+ await db
218+ . update ( document )
219+ . set ( { processingQueueToken : 'replacement-generation' } )
220+ . where ( eq ( document . id , file . documentId ) )
221+ return { data : [ ] }
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 ( 'replacement-generation' )
227+ expect ( await eventsFor ( ids ) ) . toHaveLength ( 0 )
228+ await db
229+ . update ( knowledgeConnector )
230+ . set ( { status : 'paused' } )
231+ . where ( eq ( knowledgeConnector . id , ids . connectorId ) )
232+ }
233+ )
234+
235+ it . each ( [ 'pending' , 'processing' ] ) (
236+ 'does not replace an aged %s outbox continuation' ,
237+ async ( status ) => {
238+ const ids = await seed ( )
239+ const file = await failedFile ( ids )
240+ const token = generateId ( )
241+ await db
242+ . update ( document )
243+ . set ( { processingQueueToken : token } )
244+ . where ( eq ( document . id , file . documentId ) )
245+ await db . insert ( outboxEvent ) . values ( {
246+ id : token ,
247+ eventType : 'knowledge.document.processing.resume' ,
248+ payload : { knowledgeBaseId : ids . knowledgeBaseId , documentId : file . documentId } ,
249+ status,
250+ availableAt : old ( ) ,
251+ } )
252+ expect ( await recoverFixture ( ids , 'independent' ) ) . toBe ( 0 )
253+ expect ( await recoverFixture ( ids , 'connector' ) ) . toBe ( 0 )
254+ const [ row ] = await db . select ( ) . from ( document ) . where ( eq ( document . id , file . documentId ) )
255+ expect ( row . processingAttempts ) . toBe ( 1 )
256+ expect ( row . processingQueueToken ) . toBe ( token )
257+ }
258+ )
259+
141260 it ( 'uses the organization owner and preserves Search visibility during source backoff' , async ( ) => {
142261 const ids = await seed ( )
143262 await db . insert ( member ) . values ( {
0 commit comments