Skip to content

Commit 27ea72b

Browse files
committed
fix(file-search): back off indexing retries after database timeouts
A statement, lock, or transaction timeout while indexing a workspace file means the database had no capacity for the build, not that the file is bad. The task retried it within seconds, so all three attempts landed in the same slow window and the revision was marked failed for good. Capacity timeouts now retry after about 2, 4, 8, 16 and 30 minutes (six attempts); other failures keep three attempts with the default delays.
1 parent fb2c3f3 commit 27ea72b

6 files changed

Lines changed: 148 additions & 9 deletions

File tree

‎apps/sim/background/workspace-file-search-index.test.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
55

66
const mocks = vi.hoisted(() => ({
77
indexWorkspaceFile: vi.fn(),
8+
retry: vi.fn(),
89
markFailed: vi.fn(),
910
task: vi.fn((config: unknown) => config),
1011
}))
1112

1213
vi.mock('@trigger.dev/sdk', () => ({ task: mocks.task }))
1314
vi.mock('@/lib/workspace-files/search/indexing', () => ({
1415
indexWorkspaceFileForSearch: mocks.indexWorkspaceFile,
16+
getWorkspaceFileSearchRetry: mocks.retry,
1517
markWorkspaceFileSearchIndexFailed: mocks.markFailed,
1618
}))
1719

1820
import {
21+
FILE_SEARCH_INDEX_CAPACITY_MAX_ATTEMPTS,
1922
FILE_SEARCH_INDEX_GLOBAL_CONCURRENCY,
2023
FILE_SEARCH_INDEX_MAX_DURATION_SECONDS,
2124
} from '@/lib/workspace-files/search/constants'
@@ -37,7 +40,7 @@ describe('workspace file search index task', () => {
3740
id: 'workspace-file-search-index',
3841
machine: 'medium-2x',
3942
maxDuration: FILE_SEARCH_INDEX_MAX_DURATION_SECONDS,
40-
retry: { maxAttempts: 3 },
43+
retry: { maxAttempts: FILE_SEARCH_INDEX_CAPACITY_MAX_ATTEMPTS },
4144
queue: {
4245
name: 'workspace-file-search-index',
4346
concurrencyLimit: FILE_SEARCH_INDEX_GLOBAL_CONCURRENCY,
@@ -65,4 +68,15 @@ describe('workspace file search index task', () => {
6568
await workspaceFileSearchIndexTask.onFailure({ payload })
6669
expect(mocks.markFailed).toHaveBeenCalledWith(payload)
6770
})
71+
72+
it('lets the indexing retry policy schedule each failed attempt', async () => {
73+
const error = new Error('statement timeout')
74+
const decision = { retryAt: new Date('2026-08-29T12:02:00.000Z') }
75+
mocks.retry.mockReturnValue(decision)
76+
77+
await expect(
78+
workspaceFileSearchIndexTask.catchError({ error, ctx: { attempt: { number: 2 } } })
79+
).resolves.toBe(decision)
80+
expect(mocks.retry).toHaveBeenCalledWith(error, 2)
81+
})
6882
})

‎apps/sim/background/workspace-file-search-index.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { task } from '@trigger.dev/sdk'
22
import {
3+
FILE_SEARCH_INDEX_CAPACITY_MAX_ATTEMPTS,
34
FILE_SEARCH_INDEX_GLOBAL_CONCURRENCY,
45
FILE_SEARCH_INDEX_MAX_DURATION_SECONDS,
56
} from '@/lib/workspace-files/search/constants'
67
import {
8+
getWorkspaceFileSearchRetry,
79
indexWorkspaceFileForSearch,
810
markWorkspaceFileSearchIndexFailed,
911
type WorkspaceFileSearchIndexPayload,
@@ -17,13 +19,15 @@ export const workspaceFileSearchIndexTask = task({
1719
id: 'workspace-file-search-index',
1820
machine: 'medium-2x',
1921
maxDuration: FILE_SEARCH_INDEX_MAX_DURATION_SECONDS,
20-
retry: { maxAttempts: 3 },
22+
/** The ceiling for capacity retries; `catchError` stops other failures sooner. */
23+
retry: { maxAttempts: FILE_SEARCH_INDEX_CAPACITY_MAX_ATTEMPTS },
2124
queue: {
2225
name: 'workspace-file-search-index',
2326
concurrencyLimit: FILE_SEARCH_INDEX_GLOBAL_CONCURRENCY,
2427
},
2528
run: (payload: WorkspaceFileSearchIndexPayload, { signal }) =>
2629
indexWorkspaceFileForSearch(payload, signal),
30+
catchError: async ({ error, ctx }) => getWorkspaceFileSearchRetry(error, ctx.attempt.number),
2731
onFailure: async ({ payload }) => {
2832
await markWorkspaceFileSearchIndexFailed(payload)
2933
},

‎apps/sim/lib/workspace-files/search/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Workers download and extract outside database transactions, then insert batches
1414

1515
The chunk GIN index uses `fastupdate = off`. Each bounded insert updates the main index directly instead of appending to a shared pending list. With deferred updates enabled, even a small insert can cross the pending-list threshold and synchronously merge accumulated work from other files. Direct updates trade some bulk-write throughput for avoiding that foreground cleanup cliff. They do not eliminate normal index I/O, vacuum, or storage contention; the row and worker limits still apply. Bytes alone do not bound GIN posting updates: dense text can generate thousands of distinct keys in each chunk. The batch planner sums each chunk's distinct trigram count, including repeated keys across rows, and flushes before the next chunk exceeds the key target. A single 8 KiB chunk is always allowed to make progress even if word padding pushes its estimate slightly above the target; it is written alone. Storage validates the same budget before opening the transaction. These are write scheduling bounds, not file exclusions: chunk boundaries, complete-file publication, and the 25 MiB file coverage limit are unchanged. The estimate mirrors pg_trgm under `en_US.UTF-8`; it is a work estimate, not a latency guarantee. Capacity validation must still include dense text, concurrent writers, and an index working set larger than available cache. A batch that takes at least two seconds, including a canceled statement, is logged with its rows, bytes, and estimated key count. A failed query is reported to the task runner by error code only; Drizzle's message carries the bound file text.
1616

17-
Indexing transactions have separate limits from search: ten seconds per statement, five seconds waiting for a lock, and thirty seconds total on PostgreSQL 17. The outer limit leaves time for ordinary statement cancellation and rollback instead of terminating the connection at the same ten-second deadline. PostgreSQL 16 uses the compatible idle-transaction guard. A canceled batch remains unpublished; the existing task retry starts a fresh fenced build, and cleanup retires the previous attempt. This does not automatically retry revisions already marked failed.
17+
Indexing transactions have separate limits from search: ten seconds per statement, five seconds waiting for a lock, and thirty seconds total on PostgreSQL 17. The outer limit leaves time for ordinary statement cancellation and rollback instead of terminating the connection at the same ten-second deadline. PostgreSQL 16 uses the compatible idle-transaction guard. A canceled batch remains unpublished; the task retry starts a fresh fenced build, and cleanup retires the previous attempt. One row's direct GIN insert is not interruptible, so when storage is saturated a single ordinary chunk can run well past the statement deadline and the cancellation lands only after it; smaller batches cannot prevent that. A statement, lock, or transaction timeout is therefore treated as missing database capacity rather than a bad file: the task retries it after about 2, 4, 8, 16, and 30 minutes (with jitter), six attempts in all, so the retries outlast a slow window instead of landing inside it. Other failures keep three attempts with the runner's short default delays. A run waiting to retry still holds one of its workspace's two outstanding dispatch slots. Only a revision that exhausts its attempts is marked failed; this does not automatically retry revisions already marked failed.
1818

1919
The indexing task uses an isolated `medium-2x` Trigger worker (4 GB RAM). Document parsers can materialize expanded content before chunking, so source and extracted-text byte limits do not bound parser memory. Parser complexity guards and the worker's memory budget remain separate protections.
2020

‎apps/sim/lib/workspace-files/search/constants.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,17 @@ export const FILE_SEARCH_INDEX_TRANSACTION_LIMITS = {
7878
transactionTimeout: 30 * 1000,
7979
} as const
8080

81+
/** Attempts for failures other than database capacity cancellations. */
82+
export const FILE_SEARCH_INDEX_MAX_ATTEMPTS = 3
83+
/**
84+
* Attempts when PostgreSQL cancels an indexing statement on a timeout. One row's direct GIN insert
85+
* is not interruptible, so under storage saturation even a single ordinary chunk can outlive the
86+
* statement deadline; smaller batches cannot help, only waiting out the slow window can.
87+
*/
88+
export const FILE_SEARCH_INDEX_CAPACITY_MAX_ATTEMPTS = 6
89+
/** First capacity retry delay; later ones double up to the ceiling, about an hour in total. */
90+
export const FILE_SEARCH_INDEX_CAPACITY_RETRY_BASE_MS = 2 * 60 * 1000
91+
export const FILE_SEARCH_INDEX_CAPACITY_RETRY_MAX_MS = 30 * 60 * 1000
8192
export const FILE_SEARCH_INDEX_GLOBAL_CONCURRENCY = 10
8293
export const FILE_SEARCH_INDEX_WORKSPACE_OUTSTANDING = 2
8394
export const FILE_SEARCH_INDEX_MAX_OUTSTANDING = 100

‎apps/sim/lib/workspace-files/search/indexing.test.ts‎

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,24 +24,32 @@ vi.mock('@/lib/workspace-files/search/extract', () => ({
2424
}))
2525

2626
import {
27+
FILE_SEARCH_INDEX_CAPACITY_MAX_ATTEMPTS,
28+
FILE_SEARCH_INDEX_CAPACITY_RETRY_BASE_MS,
29+
FILE_SEARCH_INDEX_CAPACITY_RETRY_MAX_MS,
30+
FILE_SEARCH_INDEX_MAX_ATTEMPTS,
2731
FILE_SEARCH_INSERT_BATCH_BYTES,
2832
FILE_SEARCH_INSERT_BATCH_ROWS,
2933
FILE_SEARCH_MAX_SOURCE_BYTES,
3034
FILE_SEARCH_SLOW_INSERT_BATCH_MS,
3135
} from '@/lib/workspace-files/search/constants'
3236
import type { FileSearchChunk } from '@/lib/workspace-files/search/index-plan'
33-
import { indexWorkspaceFileForSearch } from '@/lib/workspace-files/search/indexing'
37+
import {
38+
getWorkspaceFileSearchRetry,
39+
indexWorkspaceFileForSearch,
40+
} from '@/lib/workspace-files/search/indexing'
3441

3542
const logger = vi.mocked(createLogger).mock.results[
3643
vi.mocked(createLogger).mock.calls.findIndex(([name]) => name === 'WorkspaceFileSearchIndexer')
3744
].value as { warn: ReturnType<typeof vi.fn> }
3845

3946
const FILE_TEXT = 'confidential customer text'
4047

41-
function statementTimeout(): DrizzleQueryError {
42-
const driverError = Object.assign(new Error('canceling statement due to statement timeout'), {
43-
code: '57014',
44-
})
48+
function statementTimeout(
49+
message = 'canceling statement due to statement timeout',
50+
code = '57014'
51+
): DrizzleQueryError {
52+
const driverError = Object.assign(new Error(message), { code })
4553
return new DrizzleQueryError(
4654
'insert into "workspace_file_search_chunk" values ($1)',
4755
[FILE_TEXT],
@@ -182,3 +190,70 @@ describe('complete-file indexing worker', () => {
182190
expect(mocks.publish).not.toHaveBeenCalled()
183191
})
184192
})
193+
194+
describe('indexing retry policy', () => {
195+
const now = Date.parse('2026-01-01T00:00:00.000Z')
196+
197+
/** The error the task runner receives: the redacted wrapper the worker throws. */
198+
async function thrownBy(error: unknown): Promise<unknown> {
199+
vi.clearAllMocks()
200+
mocks.begin.mockResolvedValue({ id: 'build', ...payload })
201+
mocks.file.mockResolvedValue({
202+
name: 'notes.txt',
203+
size: 100,
204+
contentUpdatedAt: new Date(payload.sourceContentUpdatedAt),
205+
})
206+
mocks.load.mockResolvedValue({ buffer: Buffer.from('a\n') })
207+
mocks.extract.mockResolvedValue({ text: 'a\n', lineCount: 1 })
208+
mocks.append.mockRejectedValue(error)
209+
return indexWorkspaceFileForSearch(payload, signal).catch((thrown) => thrown)
210+
}
211+
212+
function delayOf(decision: ReturnType<typeof getWorkspaceFileSearchRetry>): number {
213+
if (!decision || !('retryAt' in decision)) throw new Error('expected a scheduled retry')
214+
return decision.retryAt.getTime() - now
215+
}
216+
217+
it.each([
218+
['statement timeout', 'canceling statement due to statement timeout', '57014'],
219+
['lock timeout', 'canceling statement due to lock timeout', '55P03'],
220+
])('waits minutes, not seconds, after a %s', async (_label, message, code) => {
221+
const thrown = await thrownBy(statementTimeout(message, code))
222+
const first = delayOf(getWorkspaceFileSearchRetry(thrown, 1, now))
223+
expect(first).toBeGreaterThanOrEqual(FILE_SEARCH_INDEX_CAPACITY_RETRY_BASE_MS * 0.8)
224+
expect(first).toBeLessThanOrEqual(FILE_SEARCH_INDEX_CAPACITY_RETRY_BASE_MS * 1.2)
225+
})
226+
227+
it('backs capacity retries off to a ceiling and spans a slow window', async () => {
228+
const thrown = await thrownBy(statementTimeout())
229+
let total = 0
230+
for (let attempt = 1; attempt < FILE_SEARCH_INDEX_CAPACITY_MAX_ATTEMPTS; attempt++) {
231+
const delay = delayOf(getWorkspaceFileSearchRetry(thrown, attempt, now))
232+
expect(delay).toBeLessThanOrEqual(FILE_SEARCH_INDEX_CAPACITY_RETRY_MAX_MS * 1.2)
233+
total += delay
234+
}
235+
expect(total).toBeGreaterThanOrEqual(45 * 60 * 1000)
236+
})
237+
238+
it('stops capacity retries at their attempt ceiling', async () => {
239+
const thrown = await thrownBy(statementTimeout())
240+
expect(
241+
getWorkspaceFileSearchRetry(thrown, FILE_SEARCH_INDEX_CAPACITY_MAX_ATTEMPTS, now)
242+
).toEqual({ skipRetrying: true })
243+
})
244+
245+
it('keeps the short default retries and attempt count for other failures', () => {
246+
const parserFailure = new Error('parser failed')
247+
for (let attempt = 1; attempt < FILE_SEARCH_INDEX_MAX_ATTEMPTS; attempt++) {
248+
expect(getWorkspaceFileSearchRetry(parserFailure, attempt, now)).toBeUndefined()
249+
}
250+
expect(getWorkspaceFileSearchRetry(parserFailure, FILE_SEARCH_INDEX_MAX_ATTEMPTS, now)).toEqual(
251+
{ skipRetrying: true }
252+
)
253+
})
254+
255+
it('treats a user cancellation as an ordinary failure', async () => {
256+
const thrown = await thrownBy(statementTimeout('canceling statement due to user request'))
257+
expect(getWorkspaceFileSearchRetry(thrown, 1, now)).toBeUndefined()
258+
})
259+
})

‎apps/sim/lib/workspace-files/search/indexing.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { Buffer } from 'node:buffer'
22
import { createLogger } from '@sim/logger'
3-
import { describeError } from '@sim/utils/errors'
3+
import { describeError, getPostgresCancellationReason } from '@sim/utils/errors'
4+
import { backoffWithJitter } from '@sim/utils/retry'
45
import { redactDatabaseQueryError } from '@/lib/core/errors/database-query-error'
56
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
67
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
78
import {
9+
FILE_SEARCH_INDEX_CAPACITY_MAX_ATTEMPTS,
10+
FILE_SEARCH_INDEX_CAPACITY_RETRY_BASE_MS,
11+
FILE_SEARCH_INDEX_CAPACITY_RETRY_MAX_MS,
12+
FILE_SEARCH_INDEX_MAX_ATTEMPTS,
813
FILE_SEARCH_MAX_SOURCE_BYTES,
914
FILE_SEARCH_SLOW_INSERT_BATCH_MS,
1015
} from '@/lib/workspace-files/search/constants'
@@ -154,3 +159,33 @@ export async function markWorkspaceFileSearchIndexFailed(
154159
return
155160
await failFileSearchRevision(parseRevision(payload), payload.dispatchToken)
156161
}
162+
163+
const CAPACITY_CANCELLATIONS = new Set(['statement_timeout', 'lock_timeout', 'transaction_timeout'])
164+
165+
export type WorkspaceFileSearchRetryDecision =
166+
| { retryAt: Date }
167+
| { skipRetrying: true }
168+
| undefined
169+
170+
/**
171+
* Chooses the next attempt after `attempt` (1-based) failed. A statement, lock, or transaction
172+
* timeout means the database had no capacity for this build right now, not that the file is bad:
173+
* those back off for minutes so the retries outlast a slow window instead of all landing inside
174+
* it. Anything else keeps the ordinary short retries. `undefined` keeps the runner's default delay.
175+
*/
176+
export function getWorkspaceFileSearchRetry(
177+
error: unknown,
178+
attempt: number,
179+
now = Date.now()
180+
): WorkspaceFileSearchRetryDecision {
181+
const reason = getPostgresCancellationReason(error)
182+
if (reason && CAPACITY_CANCELLATIONS.has(reason)) {
183+
if (attempt >= FILE_SEARCH_INDEX_CAPACITY_MAX_ATTEMPTS) return { skipRetrying: true }
184+
const delayMs = backoffWithJitter(attempt, null, {
185+
baseMs: FILE_SEARCH_INDEX_CAPACITY_RETRY_BASE_MS,
186+
maxMs: FILE_SEARCH_INDEX_CAPACITY_RETRY_MAX_MS,
187+
})
188+
return { retryAt: new Date(now + delayMs) }
189+
}
190+
return attempt >= FILE_SEARCH_INDEX_MAX_ATTEMPTS ? { skipRetrying: true } : undefined
191+
}

0 commit comments

Comments
 (0)