Skip to content

Commit 4c96292

Browse files
committed
fix(knowledge): stop the fill page retry at the run budget and only on statement timeouts
1 parent 7ea6231 commit 4c96292

2 files changed

Lines changed: 56 additions & 13 deletions

File tree

‎packages/db/script-migrations/0021_embedding_search_connector.test.ts‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,15 @@ const untouched = { begin: vi.fn() } as unknown as Sql
1313

1414
type PageRow = { scanned: number; filled: number; last_id: string | null }
1515

16+
/** The message the database pairs with each cancellation SQLSTATE. */
17+
const CANCELLATION_MESSAGES: Record<string, string> = {
18+
'55P03': 'canceling statement due to lock timeout',
19+
'57014': 'canceling statement due to statement timeout',
20+
}
21+
1622
/** A driver error carrying a SQLSTATE, the shape `postgres` throws. */
17-
function postgresError(code: string): Error {
18-
return Object.assign(new Error(`canceling statement (SQLSTATE ${code})`), { code })
23+
function postgresError(code: string, message = CANCELLATION_MESSAGES[code] ?? 'failed'): Error {
24+
return Object.assign(new Error(`${message} (SQLSTATE ${code})`), { code })
1925
}
2026

2127
/**
@@ -95,6 +101,27 @@ describe('backfillProjectionSourceAcl', () => {
95101
expect(cursors).toEqual([''])
96102
})
97103

104+
it('propagates an explicit cancellation, which shares the statement timeout SQLSTATE', async () => {
105+
const { session, cursors } = sessionOf([
106+
postgresError('57014', 'canceling statement due to user request'),
107+
])
108+
await expect(backfillNow(session, 'embedding_search', { pauseMs: 0 })).rejects.toThrow(
109+
'user request'
110+
)
111+
expect(cursors).toEqual([''])
112+
})
113+
114+
it('does not start another page when the budget ran out during the retry pause', async () => {
115+
const { session, cursors } = sessionOf([
116+
{ scanned: 1, filled: 1, last_id: 'id-1' },
117+
postgresError('57014'),
118+
])
119+
await expect(
120+
backfillNow(session, 'embedding_search', { pauseMs: 0, budgetMs: 1000 })
121+
).resolves.toMatchObject({ afterId: 'id-1', done: false })
122+
expect(cursors).toEqual(['', 'id-1'])
123+
})
124+
98125
it('leaves a page still failing at the budget to the continuation, from the last committed page', async () => {
99126
const { session, cursors } = sessionOf(
100127
[{ scanned: 1, filled: 1, last_id: 'id-1' }, postgresError('57014')],

‎packages/db/script-migrations/0021_embedding_search_connector.ts‎

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,13 @@ export const PROJECTION_SOURCE_ACL_PAGE_TIMEOUT_MS = 60_000
2525
/**
2626
* How many times in a row one page may time out before the run fails. Index maintenance was
2727
* observed holding the page for several minutes; with the pauses below a page waits roughly
28-
* twelve minutes, so the budget covers one such pass.
28+
* twelve minutes, about fourteen at the jitter's worst, so the budget covers one such pass.
2929
*/
3030
export const PROJECTION_SOURCE_ACL_PAGE_RETRIES = 12
3131

32-
/** Pause before a page is retried: 10 s, doubling to 60 s, with jitter. */
32+
/** Pause before a page is retried: 10 s, doubling to a 60 s base with up to 20% jitter (about 72 s). */
3333
const PAGE_RETRY_PAUSE = { baseMs: 10_000, maxMs: 60_000 } as const
3434

35-
/**
36-
* The two ways the database cancels a page: `lock_timeout` (55P03) while the page's index write
37-
* waits on a lock the index's background maintenance holds, and `statement_timeout` (57014) when
38-
* the page itself runs past {@link PROJECTION_SOURCE_ACL_PAGE_TIMEOUT_MS}. Both pass once the
39-
* maintenance moves on, so both are retried the same way.
40-
*/
41-
const PAGE_TIMEOUT_CODES: ReadonlySet<string> = new Set(['55P03', '57014'])
42-
4335
/** The SQLSTATE on a driver error, or on the error it wraps. */
4436
function postgresErrorCode(error: unknown): string | undefined {
4537
if (typeof error !== 'object' || error === null) return undefined
@@ -48,6 +40,28 @@ function postgresErrorCode(error: unknown): string | undefined {
4840
return postgresErrorCode((error as { cause?: unknown }).cause)
4941
}
5042

43+
/** The message on a driver error, or on the error it wraps. */
44+
function postgresErrorMessage(error: unknown): string | undefined {
45+
if (typeof error !== 'object' || error === null) return undefined
46+
const message = (error as { message?: unknown }).message
47+
if (typeof message === 'string') return message
48+
return postgresErrorMessage((error as { cause?: unknown }).cause)
49+
}
50+
51+
/**
52+
* The two ways the database cancels a page: `lock_timeout` (55P03) while the page's index write
53+
* waits on a lock the index's background maintenance holds, and `statement_timeout` (57014) when
54+
* the page itself runs past {@link PROJECTION_SOURCE_ACL_PAGE_TIMEOUT_MS}. Both pass once the
55+
* maintenance moves on, so both are retried the same way. 57014 is also what an explicit
56+
* cancellation raises, and that is not retried: only the message tells the two apart.
57+
*/
58+
function isPageTimeout(error: unknown): boolean {
59+
const code = postgresErrorCode(error)
60+
if (code === '55P03') return true
61+
if (code !== '57014') return false
62+
return postgresErrorMessage(error)?.includes('statement timeout') ?? false
63+
}
64+
5165
/** Pages between progress log lines. */
5266
const PROGRESS_EVERY_PAGES = 100
5367

@@ -211,8 +225,8 @@ export async function backfillProjectionSourceAcl(
211225
return row
212226
})
213227
} catch (error) {
228+
if (!isPageTimeout(error)) throw error
214229
const code = postgresErrorCode(error)
215-
if (code === undefined || !PAGE_TIMEOUT_CODES.has(code)) throw error
216230
timeouts += 1
217231
if (timeouts > PROJECTION_SOURCE_ACL_PAGE_RETRIES) throw error
218232
if (Date.now() >= deadline) break
@@ -225,6 +239,8 @@ export async function backfillProjectionSourceAcl(
225239
retryInMs: Math.round(pauseMs),
226240
})
227241
await sleep(pauseMs)
242+
/** Checked again after the pause, so a timeout at the budget cannot start another page. */
243+
if (Date.now() >= deadline) break
228244
continue
229245
}
230246
timeouts = 0

0 commit comments

Comments
 (0)