Skip to content

Commit 0270458

Browse files
fix(knowledge): make indexing and connector recovery durable (#7618)
* fix(knowledge): make indexing and connector recovery durable * fix(helm): bump chart version for indexing job limits * fix(knowledge): protect connector uploads during attachment
1 parent 85a3cd8 commit 0270458

138 files changed

Lines changed: 37753 additions & 986 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,3 +231,13 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
231231
# Agent tool-call loop (Optional). Model round trips one Agent block takes before it
232232
# must answer. Defaults to 20; raise it for agents that chain many tool calls.
233233
# MAX_TOOL_ITERATIONS=20
234+
235+
# Mistral OCR capacity (regular KBs, Sim Search and Mistral tools share these limits)
236+
# Use operating ceilings below the organization's actual quota, allowing for other clients.
237+
# KB_CONFIG_OCR_REQUESTS_PER_MINUTE=60
238+
# KB_CONFIG_MISTRAL_OCR_PAGES_PER_MINUTE=1000
239+
# KB_CONFIG_MISTRAL_OCR_PAGES_PER_REQUEST=30
240+
# KB_CONFIG_MISTRAL_OCR_MAX_CONCURRENT=2
241+
# Hosted MISTRAL_API_KEY requests share capacity across key rotation. Map any additional
242+
# keys in the same organization to one group using SHA-256 fingerprints, never raw keys.
243+
# MISTRAL_OCR_QUOTA_GROUPS={"<64-character lowercase key fingerprint>":"organization-id"}

apps/sim/app/api/v2/knowledge/connector-utils.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,18 @@ export function toV2KnowledgeConnectorDetail(
8989
...toV2KnowledgeConnector(connector),
9090
syncLogs: connector.syncLogs.map((log) =>
9191
v2KnowledgeConnectorSyncLogSchema.parse({
92-
...log,
92+
id: log.id,
93+
connectorId: log.connectorId,
94+
status: log.status,
9395
startedAt: serializeDate(log.startedAt),
9496
completedAt: serializeNullableDate(log.completedAt),
97+
docsAdded: log.docsAdded,
98+
docsUpdated: log.docsUpdated,
99+
docsDeleted: log.docsDeleted,
100+
docsUnchanged: log.docsUnchanged,
101+
docsSkipped: log.docsSkipped,
102+
docsFailed: log.docsFailed,
103+
errorMessage: log.errorMessage,
95104
})
96105
),
97106
})

apps/sim/app/api/webhooks/outbox/process/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { reapStaleBackgroundWork } from '@/ee/workspace-forking/lib/background-w
2424
const logger = createLogger('OutboxProcessorAPI')
2525

2626
export const dynamic = 'force-dynamic'
27-
export const maxDuration = 120
27+
export const maxDuration = 800
2828

2929
const handlers = {
3030
...adminInvitationOperationOutboxHandlers,
@@ -53,7 +53,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
5353

5454
const result = await processOutboxEvents(handlers, {
5555
batchSize: 20,
56-
maxRuntimeMs: 110_000,
56+
maxRuntimeMs: 790_000,
5757
minRemainingMs: 95_000,
5858
})
5959

apps/sim/background/knowledge-processing.test.ts

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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'
2930
import { EmbeddingAPIError, EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client'
3031
import { EMBEDDING_QUOTA_CIRCUIT_TTL_MS } from '@/lib/embeddings/quota-circuit'
3132
import {
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'
3538
import { MAX_QUOTA_CONTINUATION_ATTEMPTS } from '@/lib/knowledge/documents/processing-quota-continuation'
39+
import type { DocumentProcessingAttemptContext } from '@/lib/knowledge/documents/service'
3640
import {
3741
resolveQuotaContinuationDelayMs,
3842
runDocumentProcessing,
@@ -91,7 +95,7 @@ const ORGANIZATION_PAYLOAD = {
9195
function 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

apps/sim/background/knowledge-processing.ts

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,21 @@ import {
88
isEmbeddingQuotaExhaustion,
99
} from '@/lib/embeddings'
1010
import {
11+
getOcrRequestRejection,
1112
isPermanentDocumentProcessingError,
1213
isUsageLimitDocumentProcessingError,
1314
} from '@/lib/knowledge/documents/document-processing-error'
1415
import {
1516
assertDocumentProcessingBillingContext,
1617
assertDocumentProcessingPayload,
1718
type DocumentProcessingPayload,
19+
shouldRefundDocumentProcessingPredecessor,
1820
} from '@/lib/knowledge/documents/processing-payload'
21+
import { scheduleDocumentProcessingProviderContinuation } from '@/lib/knowledge/documents/processing-provider-continuation'
22+
import {
23+
getProviderCapacityDeferral,
24+
ProviderCapacityContinuationExhaustedError,
25+
} from '@/lib/knowledge/documents/processing-provider-deferral'
1926
import {
2027
canScheduleDocumentProcessingQuotaContinuation,
2128
MAX_QUOTA_CONTINUATION_ATTEMPTS,
@@ -36,6 +43,12 @@ export async function runDocumentProcessing(
3643
const { knowledgeBaseId, documentId, docData, processingOptions, requestId } = payload
3744
const billingContext = assertDocumentProcessingBillingContext(payload)
3845
const canScheduleQuotaContinuation = canScheduleDocumentProcessingQuotaContinuation(payload)
46+
const chargedAtDispatch =
47+
(payload.chargedAtDispatch ?? payload.processingQueuedAt !== undefined) &&
48+
attemptNumber === 1 &&
49+
payload.quotaRetryCount === undefined &&
50+
payload.providerRetryCount === undefined &&
51+
payload.processingSliceCount === undefined
3952

4053
logger.info(`[${requestId}] Starting Trigger.dev processing for document: ${docData.filename}`)
4154

@@ -48,10 +61,13 @@ export async function runDocumentProcessing(
4861
billingContext,
4962
requestId,
5063
{
51-
chargedAtDispatch:
52-
(payload.chargedAtDispatch ?? payload.processingQueuedAt !== undefined) &&
53-
attemptNumber === 1 &&
54-
payload.quotaRetryCount === undefined,
64+
chargedAtDispatch,
65+
...(payload.processingPredecessorToken
66+
? {
67+
processingPredecessorToken: payload.processingPredecessorToken,
68+
refundPredecessorAdmission: shouldRefundDocumentProcessingPredecessor(payload),
69+
}
70+
: {}),
5571
...(payload.processingQueueToken
5672
? { processingQueueToken: payload.processingQueueToken }
5773
: {}),
@@ -60,9 +76,12 @@ export async function runDocumentProcessing(
6076
: {}),
6177
...(canScheduleQuotaContinuation
6278
? {
63-
scheduleQuotaContinuation: () => scheduleDocumentProcessingQuotaContinuation(payload),
79+
scheduleQuotaContinuation: () =>
80+
scheduleDocumentProcessingQuotaContinuation(payload, true, chargedAtDispatch),
6481
}
6582
: { quotaContinuationExhausted: true }),
83+
scheduleProviderContinuation: (error) =>
84+
scheduleDocumentProcessingProviderContinuation(payload, error, true, chargedAtDispatch),
6685
}
6786
)
6887

@@ -75,6 +94,30 @@ export async function runDocumentProcessing(
7594
processingTime: Date.now() - startedAt,
7695
}
7796
} catch (error) {
97+
const providerDeferral = getProviderCapacityDeferral(error)
98+
if (providerDeferral || error instanceof ProviderCapacityContinuationExhaustedError) {
99+
const outcome =
100+
error instanceof ProviderCapacityContinuationExhaustedError
101+
? 'provider_exhausted'
102+
: 'provider_deferred'
103+
logger.warn(`[${requestId}] Document processing is waiting for provider recovery`, {
104+
documentId,
105+
providerRetryCount: payload.providerRetryCount ?? 0,
106+
reason: providerDeferral?.reason,
107+
outcome,
108+
})
109+
return {
110+
success: false,
111+
outcome,
112+
documentId,
113+
filename: docData.filename,
114+
error:
115+
error instanceof ProviderCapacityContinuationExhaustedError
116+
? error.message
117+
: providerDeferral!.message,
118+
processingTime: Date.now() - startedAt,
119+
}
120+
}
78121
if (isUsageLimitDocumentProcessingError(error)) {
79122
logger.warn(`[${requestId}] Document processing is blocked by the current usage limit`, {
80123
filename: docData.filename,
@@ -120,6 +163,22 @@ export async function runDocumentProcessing(
120163
processingTime: Date.now() - startedAt,
121164
}
122165
}
166+
const ocrRejection = getOcrRequestRejection(error)
167+
if (ocrRejection) {
168+
logger.warn(`[${requestId}] OCR request requires remediation before retrying`, {
169+
status: ocrRejection.status,
170+
code: ocrRejection.code,
171+
})
172+
return {
173+
success: false,
174+
outcome: 'provider_request_rejected' as const,
175+
documentId,
176+
filename: docData.filename,
177+
code: ocrRejection.code,
178+
error: ocrRejection.message,
179+
processingTime: Date.now() - startedAt,
180+
}
181+
}
123182
if (isPermanentDocumentProcessingError(error)) {
124183
logger.warn(`[${requestId}] Document cannot be processed without changing its content`, {
125184
code: error.code,

0 commit comments

Comments
 (0)