@@ -26,13 +26,17 @@ vi.mock('@/lib/knowledge/documents/service', () => ({
2626 processDocumentAsync : mockProcessDocumentAsync ,
2727} ) )
2828
29+ import { ProviderCapacityDeferredError } from '@/lib/core/rate-limiter/provider-capacity-error'
2930import { EmbeddingAPIError , EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client'
3031import { EMBEDDING_QUOTA_CIRCUIT_TTL_MS } from '@/lib/embeddings/quota-circuit'
3132import {
33+ OcrRequestRejectedError ,
3234 PermanentDocumentProcessingError ,
3335 UsageLimitDocumentProcessingError ,
3436} from '@/lib/knowledge/documents/document-processing-error'
37+ import { MAX_PROVIDER_CONTINUATION_ATTEMPTS } from '@/lib/knowledge/documents/processing-provider-continuation'
3538import { MAX_QUOTA_CONTINUATION_ATTEMPTS } from '@/lib/knowledge/documents/processing-quota-continuation'
39+ import type { DocumentProcessingAttemptContext } from '@/lib/knowledge/documents/service'
3640import {
3741 resolveQuotaContinuationDelayMs ,
3842 runDocumentProcessing ,
@@ -91,7 +95,7 @@ const ORGANIZATION_PAYLOAD = {
9195function mockQuotaExhaustion ( error : EmbeddingQuotaExhaustedError ) : void {
9296 mockProcessDocumentAsync . mockImplementation ( async ( ...args : unknown [ ] ) => {
9397 const attemptContext = args [ 6 ] as {
94- scheduleQuotaContinuation ?: ( ) => Promise < Date >
98+ scheduleQuotaContinuation ?: ( ) => Promise < unknown >
9599 }
96100 await attemptContext . scheduleQuotaContinuation ?.( )
97101 throw error
@@ -321,6 +325,39 @@ describe('knowledge processing worker', () => {
321325 )
322326 } )
323327
328+ it ( 'carries the actual parent admission flag when Trigger attempt two hands off quickly' , async ( ) => {
329+ const error = new ProviderCapacityDeferredError ( 'rate_limit' )
330+ mockProcessDocumentAsync . mockImplementation ( async ( ...args : unknown [ ] ) => {
331+ const context = args [ 6 ] as {
332+ scheduleProviderContinuation : ( error : ProviderCapacityDeferredError ) => Promise < unknown >
333+ }
334+ await context . scheduleProviderContinuation ( error )
335+ throw error
336+ } )
337+ await runDocumentProcessing (
338+ { ...WORKSPACE_PAYLOAD , processingQueueToken : 'request-1' , chargedAtDispatch : true } ,
339+ 2
340+ )
341+ expect ( mockTrigger . mock . calls [ 0 ] [ 1 ] ) . toMatchObject ( {
342+ processingPredecessorToken : 'request-1' ,
343+ processingPredecessorCharged : false ,
344+ } )
345+ } )
346+
347+ it ( 'does not refund the original dispatch again when a healthy processing slice resumes' , async ( ) => {
348+ await runDocumentProcessing ( {
349+ ...WORKSPACE_PAYLOAD ,
350+ processingQueueToken : 'knowledge-slice-document-1-request-1-1' ,
351+ processingSliceCount : 1 ,
352+ providerRetryStartedAt : new Date ( ) . toISOString ( ) ,
353+ chargedAtDispatch : true ,
354+ } )
355+ expect ( mockProcessDocumentAsync . mock . calls [ 0 ] [ 6 ] ) . toMatchObject ( {
356+ chargedAtDispatch : false ,
357+ processingQueueToken : 'knowledge-slice-document-1-request-1-1' ,
358+ } )
359+ } )
360+
324361 it ( 'reports elapsed processing time rather than an epoch timestamp' , async ( ) => {
325362 vi . spyOn ( Date , 'now' ) . mockReturnValueOnce ( 1_000 ) . mockReturnValueOnce ( 1_125 )
326363
@@ -357,6 +394,29 @@ describe('knowledge processing worker', () => {
357394 } )
358395 } )
359396
397+ it ( 'completes provider-rejected OCR runs without requesting futile Trigger retries' , async ( ) => {
398+ mockProcessDocumentAsync . mockRejectedValue (
399+ new Error ( 'OCR chunk batch failed' , {
400+ cause : new AggregateError ( [
401+ new OcrRequestRejectedError ( 400 ) ,
402+ new ProviderCapacityDeferredError ( 'rate_limit' ) ,
403+ ] ) ,
404+ } )
405+ )
406+ await expect (
407+ runDocumentProcessing ( {
408+ ...BASE_PAYLOAD ,
409+ billingScope : 'non-workspace' ,
410+ actorUserId : 'legacy-owner' ,
411+ workspaceId : null ,
412+ } )
413+ ) . resolves . toMatchObject ( {
414+ success : false ,
415+ outcome : 'provider_request_rejected' ,
416+ code : 'ocr_request_rejected' ,
417+ } )
418+ } )
419+
360420 it ( 'reports a mutable usage-limit outcome without requesting an immediate retry' , async ( ) => {
361421 mockProcessDocumentAsync . mockRejectedValue (
362422 new UsageLimitDocumentProcessingError ( 'Usage limit exceeded. Upgrade to continue.' )
@@ -430,7 +490,8 @@ describe('knowledge processing worker', () => {
430490 expect . objectContaining ( {
431491 documentId : 'document-1' ,
432492 requestId : 'request-1' ,
433- processingQueuedAt : BASE_PAYLOAD . processingQueuedAt ,
493+ processingQueueToken : 'knowledge-quota-document-1-request-1-1' ,
494+ processingQueuedAt : expect . any ( String ) ,
434495 quotaRetryCount : 1 ,
435496 } ) ,
436497 expect . objectContaining ( {
@@ -444,6 +505,78 @@ describe('knowledge processing worker', () => {
444505 expect ( delay . getTime ( ) ) . toBeLessThanOrEqual ( 1_000 + EMBEDDING_QUOTA_CIRCUIT_TTL_MS * 1.2 )
445506 } )
446507
508+ it ( 'continues provider pressure beyond the task retry budget without admitting another pass' , async ( ) => {
509+ const error = new ProviderCapacityDeferredError ( 'rate_limit' , { retryAfterMs : 600_000 } )
510+ mockProcessDocumentAsync . mockImplementation ( async ( ...args : unknown [ ] ) => {
511+ await ( args [ 6 ] as DocumentProcessingAttemptContext ) . scheduleProviderContinuation ! ( error )
512+ throw error
513+ } )
514+ const now = Date . now ( )
515+ await expect (
516+ runDocumentProcessing (
517+ {
518+ ...WORKSPACE_PAYLOAD ,
519+ processingQueueToken : 'request-1' ,
520+ providerRetryCount : 3 ,
521+ providerRetryStartedAt : new Date ( now ) . toISOString ( ) ,
522+ } ,
523+ 3
524+ )
525+ ) . resolves . toMatchObject ( { outcome : 'provider_deferred' } )
526+ expect ( mockProcessDocumentAsync ) . toHaveBeenCalledWith (
527+ expect . anything ( ) ,
528+ expect . anything ( ) ,
529+ expect . anything ( ) ,
530+ expect . anything ( ) ,
531+ expect . anything ( ) ,
532+ 'request-1' ,
533+ expect . objectContaining ( { chargedAtDispatch : false , processingQueueToken : 'request-1' } )
534+ )
535+ expect ( mockTrigger ) . toHaveBeenCalledWith (
536+ 'knowledge-process-document' ,
537+ expect . objectContaining ( {
538+ requestId : 'request-1' ,
539+ processingQueueToken : 'knowledge-provider-document-1-request-1-4' ,
540+ providerRetryCount : 4 ,
541+ billingAttribution : BILLING_ATTRIBUTION ,
542+ } ) ,
543+ expect . objectContaining ( { idempotencyKey : 'knowledge-provider-document-1-request-1-4' } )
544+ )
545+ expect ( ( mockTrigger . mock . calls [ 0 ] [ 2 ] . delay as Date ) . getTime ( ) ) . toBeGreaterThanOrEqual (
546+ now + 600_000
547+ )
548+ } )
549+
550+ it ( 'reports provider recovery exhaustion as an actionable terminal outcome' , async ( ) => {
551+ mockProcessDocumentAsync . mockImplementation ( async ( ...args : unknown [ ] ) => {
552+ await ( args [ 6 ] as DocumentProcessingAttemptContext ) . scheduleProviderContinuation ! (
553+ new ProviderCapacityDeferredError ( 'rate_limit' )
554+ )
555+ } )
556+ await expect (
557+ runDocumentProcessing ( {
558+ ...WORKSPACE_PAYLOAD ,
559+ providerRetryCount : MAX_PROVIDER_CONTINUATION_ATTEMPTS ,
560+ providerRetryStartedAt : new Date ( ) . toISOString ( ) ,
561+ } )
562+ ) . resolves . toMatchObject ( {
563+ outcome : 'provider_exhausted' ,
564+ error : expect . stringContaining ( 'then retry this document' ) ,
565+ } )
566+ expect ( mockTrigger ) . not . toHaveBeenCalled ( )
567+ } )
568+
569+ it ( 'retries failed provider continuation dispatch instead of reporting a successful deferral' , async ( ) => {
570+ const error = new Error ( 'Trigger dispatch unavailable' )
571+ mockTrigger . mockRejectedValue ( error )
572+ mockProcessDocumentAsync . mockImplementation ( async ( ...args : unknown [ ] ) => {
573+ await ( args [ 6 ] as DocumentProcessingAttemptContext ) . scheduleProviderContinuation ! (
574+ new ProviderCapacityDeferredError ( 'rate_limit' )
575+ )
576+ } )
577+ await expect ( runDocumentProcessing ( WORKSPACE_PAYLOAD ) ) . rejects . toBe ( error )
578+ } )
579+
447580 it ( 'ends a quota chain after the bounded continuation horizon' , async ( ) => {
448581 mockQuotaExhaustion ( new EmbeddingQuotaExhaustedError ( 'openai' ) )
449582
0 commit comments