Skip to content

Commit 03ef2c9

Browse files
committed
fix(jobs): classify transient database failures once for background retries
- classifyDatabaseFailure in @sim/utils/errors: capacity / conflict / connection / permanent - shared Trigger catchError decision helper with minute-scale database backoff - workspace file search, document processing, connector sync, and the 0021 backfill adopt it - a scheduled document retry leaves the document pending instead of failed - a transient database failure no longer counts toward connector auto-disable
1 parent 1c597fe commit 03ef2c9

15 files changed

Lines changed: 817 additions & 88 deletions

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

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ import { MAX_PROVIDER_CONTINUATION_ATTEMPTS } from '@/lib/knowledge/documents/pr
4646
import { MAX_QUOTA_CONTINUATION_ATTEMPTS } from '@/lib/knowledge/documents/processing-quota-continuation'
4747
import type { DocumentProcessingAttemptContext } from '@/lib/knowledge/documents/service'
4848
import {
49+
DOCUMENT_PROCESSING_RETRY_POLICY,
50+
DocumentProcessingDatabaseRetryError,
51+
getDocumentProcessingRetry,
4952
resolveQuotaContinuationDelayMs,
5053
runDocumentProcessing,
5154
} from '@/background/knowledge-processing'
@@ -607,10 +610,83 @@ describe('knowledge processing worker', () => {
607610
)
608611
expect(failure).toBeInstanceOf(Error)
609612
expect(failure).toMatchObject({ message: 'Database request failed (SQLSTATE 57014).' })
610-
expect(failure).not.toHaveProperty('cause')
613+
/** Trigger records only the name, message and stack; the cause stays for classification. */
614+
expect((failure as Error).cause).toBe(error)
615+
expect((failure as Error).stack).not.toContain('private')
611616
expect(JSON.stringify(failure)).not.toContain('private')
612617
})
613618

619+
describe('transient database failures', () => {
620+
const MINUTE = 60 * 1000
621+
const statementTimeout = () =>
622+
new DrizzleQueryError(
623+
'insert private SQL',
624+
['private bound content'],
625+
Object.assign(new Error('canceling statement due to statement timeout'), {
626+
code: '57014',
627+
})
628+
)
629+
630+
/** A service failure that asks the worker whether to schedule a database retry, as the service does. */
631+
function failProcessingWith(error: Error): { scheduled: Array<Date | null> } {
632+
const scheduled: Array<Date | null> = []
633+
mockProcessDocumentAsync.mockImplementation(async (...args: unknown[]) => {
634+
const context = args[6] as DocumentProcessingAttemptContext
635+
scheduled.push(context.scheduleDatabaseRetry?.(error) ?? null)
636+
throw error
637+
})
638+
return { scheduled }
639+
}
640+
641+
it('schedules a minute-scale retry and hands Trigger the same time the document records', async () => {
642+
const error = statementTimeout()
643+
const { scheduled } = failProcessingWith(error)
644+
const startedAt = Date.now()
645+
646+
const failure = await runDocumentProcessing(WORKSPACE_PAYLOAD, 1).catch(
647+
(caught: unknown) => caught
648+
)
649+
650+
expect(failure).toBeInstanceOf(DocumentProcessingDatabaseRetryError)
651+
expect(failure).toMatchObject({ message: 'Database request failed (SQLSTATE 57014).' })
652+
expect((failure as Error).cause).toBe(error)
653+
expect((failure as Error).stack).not.toContain('private')
654+
const retryAt = scheduled[0]
655+
expect(retryAt).toBeInstanceOf(Date)
656+
expect(retryAt!.getTime() - startedAt).toBeGreaterThanOrEqual(2 * MINUTE * 0.8)
657+
expect(retryAt!.getTime() - startedAt).toBeLessThanOrEqual(2 * MINUTE * 1.2 + 1000)
658+
expect(getDocumentProcessingRetry(failure, 1)).toEqual({ retryAt })
659+
})
660+
661+
it('records the failure and stops once the database attempts are spent', async () => {
662+
const error = statementTimeout()
663+
const { scheduled } = failProcessingWith(error)
664+
const lastAttempt = DOCUMENT_PROCESSING_RETRY_POLICY.database.maxAttempts
665+
666+
const failure = await runDocumentProcessing(WORKSPACE_PAYLOAD, lastAttempt).catch(
667+
(caught: unknown) => caught
668+
)
669+
670+
expect(scheduled).toEqual([null])
671+
expect(failure).not.toBeInstanceOf(DocumentProcessingDatabaseRetryError)
672+
expect((failure as Error).cause).toBe(error)
673+
expect(getDocumentProcessingRetry(failure, lastAttempt)).toEqual({ skipRetrying: true })
674+
})
675+
676+
it('leaves other failures on the task retry settings and attempt count', async () => {
677+
const error = new Error('Storage request timed out')
678+
const { scheduled } = failProcessingWith(error)
679+
680+
await expect(runDocumentProcessing(WORKSPACE_PAYLOAD, 1)).rejects.toBe(error)
681+
682+
expect(scheduled).toEqual([null])
683+
expect(getDocumentProcessingRetry(error, 1)).toBeUndefined()
684+
expect(
685+
getDocumentProcessingRetry(error, DOCUMENT_PROCESSING_RETRY_POLICY.maxAttempts)
686+
).toEqual({ skipRetrying: true })
687+
})
688+
})
689+
614690
it('retries failed provider continuation dispatch instead of reporting a successful deferral', async () => {
615691
const error = new Error('Trigger dispatch unavailable')
616692
mockTrigger.mockRejectedValue(error)
@@ -750,6 +826,28 @@ describe('knowledge-process-document task configuration', () => {
750826
expect(processDocument.retry?.outOfMemory?.machine).toBe('large-2x')
751827
})
752828

829+
it('declares enough attempts for database retries and routes failures through catchError', async () => {
830+
const { processDocument } = await import('@/background/knowledge-processing')
831+
832+
expect(processDocument.retry?.maxAttempts).toBe(
833+
Math.max(
834+
DOCUMENT_PROCESSING_RETRY_POLICY.maxAttempts,
835+
DOCUMENT_PROCESSING_RETRY_POLICY.database.maxAttempts
836+
)
837+
)
838+
const retryAt = new Date('2026-01-01T00:02:00.000Z')
839+
const scheduled = new DocumentProcessingDatabaseRetryError(
840+
'Database request failed.',
841+
retryAt,
842+
{
843+
cause: new Error('private'),
844+
}
845+
)
846+
await expect(
847+
processDocument.catchError?.({ error: scheduled, ctx: { attempt: { number: 1 } } } as never)
848+
).resolves.toEqual({ retryAt })
849+
})
850+
753851
it('backs durable quota continuations off to a bounded polling interval', () => {
754852
const first = resolveQuotaContinuationDelayMs(1)
755853
const second = resolveQuotaContinuationDelayMs(2)

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

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { createLogger } from '@sim/logger'
2+
import { findCause } from '@sim/utils/errors'
23
import { queue, task } from '@trigger.dev/sdk'
34
import { env, envNumber } from '@/lib/core/config/env'
5+
import {
6+
type BackgroundRetryDecision,
7+
type BackgroundRetryPolicy,
8+
backgroundRetryAttemptCeiling,
9+
getBackgroundRetryDecision,
10+
getDatabaseRetryAt,
11+
} from '@/lib/core/errors/background-retry'
412
import {
513
BYOK_EMBEDDING_CREDENTIAL_REJECTION_MESSAGE,
614
EMBEDDING_QUOTA_EXHAUSTED_MESSAGE,
@@ -39,6 +47,46 @@ import { processDocumentAsync } from '@/lib/knowledge/documents/service'
3947
const logger = createLogger('TriggerKnowledgeProcessing')
4048
export { resolveQuotaContinuationDelayMs }
4149

50+
/**
51+
* Ordinary failures keep the configured short retries. A transient database failure backs off for
52+
* minutes, about an hour in total, so a slow database window does not exhaust every attempt inside
53+
* it and leave an uploaded document failed for good.
54+
*/
55+
export const DOCUMENT_PROCESSING_RETRY_POLICY: BackgroundRetryPolicy = {
56+
maxAttempts: envNumber(env.KB_CONFIG_MAX_ATTEMPTS, 3),
57+
database: { maxAttempts: 6, baseDelayMs: 2 * 60 * 1000, maxDelayMs: 30 * 60 * 1000 },
58+
}
59+
60+
/**
61+
* A database failure whose next attempt is already scheduled, and recorded on the document as
62+
* `pending` until {@link retryAt}. The message names only the database code; the driver error
63+
* stays in `cause`, which the task runner does not record.
64+
*/
65+
export class DocumentProcessingDatabaseRetryError extends Error {
66+
constructor(
67+
message: string,
68+
readonly retryAt: Date,
69+
options: { cause: unknown }
70+
) {
71+
super(message, options)
72+
this.name = 'DocumentProcessingDatabaseRetryError'
73+
}
74+
}
75+
76+
/** The `catchError` decision for `knowledge-process-document` after `attempt` (1-based) failed. */
77+
export function getDocumentProcessingRetry(
78+
error: unknown,
79+
attempt: number
80+
): BackgroundRetryDecision {
81+
const scheduled = findCause(
82+
error,
83+
(value): value is DocumentProcessingDatabaseRetryError =>
84+
value instanceof DocumentProcessingDatabaseRetryError
85+
)
86+
if (scheduled) return { retryAt: scheduled.retryAt }
87+
return getBackgroundRetryDecision(error, attempt, DOCUMENT_PROCESSING_RETRY_POLICY)
88+
}
89+
4290
export async function runDocumentProcessing(
4391
rawPayload: DocumentProcessingPayload,
4492
attemptNumber = 1
@@ -56,6 +104,8 @@ export async function runDocumentProcessing(
56104
payload.processingSliceCount === undefined
57105

58106
logger.info(`[${requestId}] Starting Trigger.dev processing for document: ${docData.filename}`)
107+
/** Set from the service's callback, so control-flow narrowing cannot see it change. */
108+
let databaseRetryAt = null as Date | null
59109

60110
try {
61111
const result = await processDocumentAsync(
@@ -87,6 +137,14 @@ export async function runDocumentProcessing(
87137
: { quotaContinuationExhausted: true }),
88138
scheduleProviderContinuation: (error) =>
89139
scheduleDocumentProcessingProviderContinuation(payload, error, true, chargedAtDispatch),
140+
scheduleDatabaseRetry: (error) => {
141+
databaseRetryAt = getDatabaseRetryAt(
142+
error,
143+
attemptNumber,
144+
DOCUMENT_PROCESSING_RETRY_POLICY
145+
)
146+
return databaseRetryAt
147+
},
90148
}
91149
)
92150

@@ -100,6 +158,20 @@ export async function runDocumentProcessing(
100158
processingTime: Date.now() - startedAt,
101159
}
102160
} catch (error) {
161+
if (databaseRetryAt) {
162+
const diagnostic = getConnectorFailureDiagnostic(error)
163+
logger.warn(`[${requestId}] Document processing will retry after a database failure`, {
164+
documentId,
165+
diagnostic,
166+
attempt: attemptNumber,
167+
retryAt: databaseRetryAt.toISOString(),
168+
})
169+
throw new DocumentProcessingDatabaseRetryError(
170+
diagnostic?.message ?? 'Database request failed.',
171+
databaseRetryAt,
172+
{ cause: error }
173+
)
174+
}
103175
const providerDeferral = getProviderCapacityDeferral(error)
104176
if (providerDeferral || error instanceof ProviderCapacityContinuationExhaustedError) {
105177
const outcome =
@@ -205,7 +277,8 @@ export async function runDocumentProcessing(
205277
`[${requestId}] Failed to process document: ${docData.filename}`,
206278
diagnostic ?? error
207279
)
208-
if (diagnostic?.category === 'database') throw new Error(diagnostic.message)
280+
/** Trigger records the thrown message and stack, never `cause`; Drizzle's message carries SQL. */
281+
if (diagnostic?.category === 'database') throw new Error(diagnostic.message, { cause: error })
209282
throw error
210283
}
211284
}
@@ -253,7 +326,12 @@ export const processDocument = task({
253326
*/
254327
machine: 'medium-2x',
255328
retry: {
256-
maxAttempts: envNumber(env.KB_CONFIG_MAX_ATTEMPTS, 3),
329+
/**
330+
* The ceiling for database retries; `catchError` stops other failures at
331+
* `KB_CONFIG_MAX_ATTEMPTS`. An out-of-memory kill never reaches `catchError`,
332+
* so it may use the full ceiling.
333+
*/
334+
maxAttempts: backgroundRetryAttemptCeiling(DOCUMENT_PROCESSING_RETRY_POLICY),
257335
factor: envNumber(env.KB_CONFIG_RETRY_FACTOR, 2),
258336
minTimeoutInMs: envNumber(env.KB_CONFIG_MIN_TIMEOUT, 1000),
259337
maxTimeoutInMs: envNumber(env.KB_CONFIG_MAX_TIMEOUT, 10000),
@@ -272,4 +350,5 @@ export const processDocument = task({
272350
queue: interactiveProcessingQueue,
273351
run: (payload: DocumentProcessingPayload, { ctx }) =>
274352
runDocumentProcessing(payload, ctx.attempt.number),
353+
catchError: async ({ error, ctx }) => getDocumentProcessingRetry(error, ctx.attempt.number),
275354
})
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { DrizzleQueryError } from 'drizzle-orm/errors'
5+
import { describe, expect, it } from 'vitest'
6+
import {
7+
type BackgroundRetryDecision,
8+
type BackgroundRetryPolicy,
9+
backgroundRetryAttemptCeiling,
10+
getBackgroundRetryDecision,
11+
getDatabaseRetryAt,
12+
} from '@/lib/core/errors/background-retry'
13+
14+
const MINUTE = 60 * 1000
15+
const POLICY: BackgroundRetryPolicy = {
16+
maxAttempts: 3,
17+
database: { maxAttempts: 6, baseDelayMs: 2 * MINUTE, maxDelayMs: 30 * MINUTE },
18+
}
19+
const NOW = Date.parse('2026-01-01T00:00:00.000Z')
20+
21+
function failedQuery(code: string, message = 'private driver detail'): DrizzleQueryError {
22+
return new DrizzleQueryError(
23+
'private SQL',
24+
['private'],
25+
Object.assign(new Error(message), { code })
26+
)
27+
}
28+
29+
function delayOf(decision: BackgroundRetryDecision): number {
30+
if (!decision || !('retryAt' in decision)) throw new Error('expected a scheduled retry')
31+
return decision.retryAt.getTime() - NOW
32+
}
33+
34+
describe('getBackgroundRetryDecision', () => {
35+
it.each([
36+
['capacity', failedQuery('57014', 'canceling statement due to statement timeout')],
37+
['conflict', failedQuery('40P01', 'deadlock detected')],
38+
['connection', failedQuery('CONNECTION_CLOSED')],
39+
])('waits minutes after a %s failure', (_label, error) => {
40+
const delay = delayOf(getBackgroundRetryDecision(error, 1, POLICY, NOW))
41+
expect(delay).toBeGreaterThanOrEqual(2 * MINUTE * 0.8)
42+
expect(delay).toBeLessThanOrEqual(2 * MINUTE * 1.2)
43+
})
44+
45+
it('doubles the delay per attempt up to the ceiling', () => {
46+
const error = failedQuery('55P03')
47+
const delays = [1, 2, 3, 4, 5].map((attempt) =>
48+
delayOf(getBackgroundRetryDecision(error, attempt, POLICY, NOW))
49+
)
50+
const bases = [2, 4, 8, 16, 30].map((minutes) => minutes * MINUTE)
51+
delays.forEach((delay, index) => {
52+
expect(delay).toBeGreaterThanOrEqual(bases[index] * 0.8)
53+
expect(delay).toBeLessThanOrEqual(bases[index] * 1.2)
54+
})
55+
const longPolicy = { ...POLICY, database: { ...POLICY.database, maxAttempts: 20 } }
56+
expect(delayOf(getBackgroundRetryDecision(error, 12, longPolicy, NOW))).toBeLessThanOrEqual(
57+
30 * MINUTE * 1.2
58+
)
59+
})
60+
61+
it('stops database retries at their own attempt ceiling', () => {
62+
const error = failedQuery('53300')
63+
expect(getBackgroundRetryDecision(error, 5, POLICY, NOW)).toHaveProperty('retryAt')
64+
expect(getBackgroundRetryDecision(error, 6, POLICY, NOW)).toEqual({ skipRetrying: true })
65+
})
66+
67+
it('keeps the task default for other failures until their attempt ceiling', () => {
68+
const error = failedQuery('23505')
69+
expect(getBackgroundRetryDecision(error, 1, POLICY, NOW)).toBeUndefined()
70+
expect(getBackgroundRetryDecision(error, 2, POLICY, NOW)).toBeUndefined()
71+
expect(getBackgroundRetryDecision(error, 3, POLICY, NOW)).toEqual({ skipRetrying: true })
72+
})
73+
74+
it('does not stretch an explicit cancellation onto the database pacing', () => {
75+
const cancelled = failedQuery('57014', 'canceling statement due to user request')
76+
expect(getBackgroundRetryDecision(cancelled, 1, POLICY, NOW)).toBeUndefined()
77+
})
78+
})
79+
80+
describe('getDatabaseRetryAt', () => {
81+
it('returns null for a failure that is not a transient database failure', () => {
82+
expect(getDatabaseRetryAt(new Error('parser failed'), 1, POLICY, NOW)).toBeNull()
83+
})
84+
85+
it('returns null once the database attempts are spent', () => {
86+
expect(getDatabaseRetryAt(failedQuery('40001'), 6, POLICY, NOW)).toBeNull()
87+
})
88+
})
89+
90+
describe('backgroundRetryAttemptCeiling', () => {
91+
it('covers whichever kind of failure retries longer', () => {
92+
expect(backgroundRetryAttemptCeiling(POLICY)).toBe(6)
93+
expect(backgroundRetryAttemptCeiling({ ...POLICY, maxAttempts: 8 })).toBe(8)
94+
})
95+
})

0 commit comments

Comments
 (0)