Skip to content

Commit 338e39e

Browse files
authored
fix(jobs): classify transient database failures once for background retries (#8194)
* 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 * fix(jobs): escalate database retries without spending the breaker, cover members mode - connector and members-mode syncs climb the failure ladder by the failed-run streak read from their run logs; database failures never advance the auto-disable counter - classify 25P03 as capacity and ECONNREFUSED/EHOSTUNREACH/ENOTFOUND/EAI_AGAIN (with a query) as connection - in-process document processing keeps recording database failures as failed, documented - offer Retry for a pending document whose dispatch or deferred retry is past the retry API's grace - correct the processing task's retry-ceiling comment * revert(knowledge): drop the stale-deferred Retry action and its wire fields Restores the document list, contract, serializer, and Retry condition to staging; Retry stays failed-only. * fix(knowledge): fail an uploaded document whose scheduled database retry never ran - the deferral write and a deferred-retry-check outbox event commit together for uploaded documents - the check fails the document only if the same deferral is still pending, overdue past the queue grace, and no run is live; otherwise it rechecks without spending an attempt, bounded by the recovery window - a database deferral stamps processingQueuedAt when unset, so a dispatch cannot claim the row as never-queued during the retry window * fix(knowledge): keep the deferred-retry watchdog alive through database failures A transient database failure while checking postpones the check without spending an attempt, paced by how overdue it is and capped at the recheck interval, until a terminal bound past the recovery window; other errors, and any error past that bound, spend attempts as before. * fix(jobs): progress-aware connector database retries, stuck-sync signal, strict query shapes - the database-failure streak counts only failed runs that made no progress; a run that wrote documents (or completed a member) retries in minutes - ten zero-progress database failures in a row log an alertable error; the connector is never disabled for them - the classifier's query check matches only Drizzle's query error and the postgres.js query error shapes, so a client error carrying its own query stays a source failure * fix(jobs): bound the retry-history read, count member purges, match driver connection errors - the connector run-history read runs under short statement and lock timeouts and falls back to this run alone - members-mode progress counts lifecycle purges (docs_purged in the run log) - postgres.js connection codes count only on an error the driver built, or under a query error; the driver's query signature is its four own properties, which a refused connection carries with no SQL yet - real-error PostgreSQL test for terminated transactions, refused connections, and an ending pool, wired into CI - knowledge-processing tests use the static task import * fix(jobs): classify ENETDOWN, ENETRESET and ENETUNREACH on a database query as connection failures * test(knowledge): import the connector retry helpers statically
1 parent 0c321f7 commit 338e39e

28 files changed

Lines changed: 2711 additions & 149 deletions

‎.github/workflows/test-build.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ jobs:
102102
bunx vitest run
103103
scripts/retired-columns.postgres.test.ts
104104
scripts/connector-sync-schedule-precision.postgres.test.ts
105+
scripts/database-failure-classification.postgres.test.ts
105106
106107
- name: Verify OAuth lifecycle and SCIM membership guards in PostgreSQL
107108
working-directory: apps/sim

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

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ 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,
52+
processDocument,
4953
resolveQuotaContinuationDelayMs,
5054
runDocumentProcessing,
5155
} from '@/background/knowledge-processing'
@@ -607,10 +611,83 @@ describe('knowledge processing worker', () => {
607611
)
608612
expect(failure).toBeInstanceOf(Error)
609613
expect(failure).toMatchObject({ message: 'Database request failed (SQLSTATE 57014).' })
610-
expect(failure).not.toHaveProperty('cause')
614+
/** Trigger records only the name, message and stack; the cause stays for classification. */
615+
expect((failure as Error).cause).toBe(error)
616+
expect((failure as Error).stack).not.toContain('private')
611617
expect(JSON.stringify(failure)).not.toContain('private')
612618
})
613619

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

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

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

Lines changed: 82 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,13 @@ 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 thrown errors: database retries use all of it, and
331+
* `catchError` stops every other thrown error at `KB_CONFIG_MAX_ATTEMPTS`.
332+
* A crashed or timed-out run is not retried; an out-of-memory kill is
333+
* retried once, on the `outOfMemory` machine below.
334+
*/
335+
maxAttempts: backgroundRetryAttemptCeiling(DOCUMENT_PROCESSING_RETRY_POLICY),
257336
factor: envNumber(env.KB_CONFIG_RETRY_FACTOR, 2),
258337
minTimeoutInMs: envNumber(env.KB_CONFIG_MIN_TIMEOUT, 1000),
259338
maxTimeoutInMs: envNumber(env.KB_CONFIG_MAX_TIMEOUT, 10000),
@@ -272,4 +351,5 @@ export const processDocument = task({
272351
queue: interactiveProcessingQueue,
273352
run: (payload: DocumentProcessingPayload, { ctx }) =>
274353
runDocumentProcessing(payload, ctx.attempt.number),
354+
catchError: async ({ error, ctx }) => getDocumentProcessingRetry(error, ctx.attempt.number),
275355
})
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)