Skip to content

Commit 06d0f7d

Browse files
authored
fix(knowledge): retry a projection fill page after an index lock or statement timeout instead of ending the chain (#8137)
* fix(knowledge): retry a projection fill page after an index lock or statement timeout instead of ending the chain * fix(knowledge): size the fill page retry budget to one index maintenance pass * fix(knowledge): stop the fill page retry at the run budget and only on statement timeouts
1 parent 32fcc63 commit 06d0f7d

2 files changed

Lines changed: 230 additions & 29 deletions

File tree

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

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,141 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { backfillProjectionSourceAcl } from '@sim/db/script-migrations/0021_embedding_search_connector'
4+
import {
5+
backfillProjectionSourceAcl,
6+
PROJECTION_SOURCE_ACL_PAGE_RETRIES,
7+
} from '@sim/db/script-migrations/0021_embedding_search_connector'
58
import type { Sql } from 'postgres'
6-
import { describe, expect, it, vi } from 'vitest'
9+
import { afterEach, describe, expect, it, vi } from 'vitest'
710

811
/** A session that must never be reached: every case below is refused before the first page. */
912
const untouched = { begin: vi.fn() } as unknown as Sql
1013

14+
type PageRow = { scanned: number; filled: number; last_id: string | null }
15+
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+
22+
/** A driver error carrying a SQLSTATE, the shape `postgres` throws. */
23+
function postgresError(code: string, message = CANCELLATION_MESSAGES[code] ?? 'failed'): Error {
24+
return Object.assign(new Error(`${message} (SQLSTATE ${code})`), { code })
25+
}
26+
27+
/**
28+
* A session whose page statement answers from `outcomes` in order — a row, or an error to throw —
29+
* and records the cursor each page was bound to. `beforePage` runs with the page's index before
30+
* it answers.
31+
*/
32+
function sessionOf(outcomes: Array<PageRow | Error>, beforePage?: (index: number) => void) {
33+
const cursors: string[] = []
34+
const tx = {
35+
unsafe: vi.fn(async (query: string, params?: unknown[]) => {
36+
if (!query.includes('WITH page')) return []
37+
beforePage?.(cursors.length)
38+
cursors.push(String(params?.[0]))
39+
const outcome = outcomes.shift()
40+
if (outcome === undefined) throw new Error('No outcome left for this page')
41+
if (outcome instanceof Error) throw outcome
42+
return [outcome]
43+
}),
44+
}
45+
const session = {
46+
begin: vi.fn(async (work: (tx: unknown) => Promise<unknown>) => work(tx)),
47+
unsafe: vi.fn(async () => []),
48+
} as unknown as Sql
49+
return { session, cursors }
50+
}
51+
52+
/** Runs a backfill under fake timers, so its retry pauses pass without waiting. */
53+
async function backfillNow(...args: Parameters<typeof backfillProjectionSourceAcl>) {
54+
vi.useFakeTimers()
55+
const result = backfillProjectionSourceAcl(...args)
56+
/** A rejection must not surface as unhandled while the timers are still being drained. */
57+
const settled = result.catch(() => undefined)
58+
await vi.runAllTimersAsync()
59+
await settled
60+
return result
61+
}
62+
1163
describe('backfillProjectionSourceAcl', () => {
64+
afterEach(() => {
65+
vi.useRealTimers()
66+
})
67+
68+
it.each(['55P03', '57014'])(
69+
'retries the page after a %s timeout and moves the cursor only once it commits',
70+
async (code) => {
71+
const { session, cursors } = sessionOf([
72+
{ scanned: 2, filled: 2, last_id: 'id-2' },
73+
postgresError(code),
74+
postgresError(code),
75+
{ scanned: 1, filled: 1, last_id: 'id-3' },
76+
{ scanned: 0, filled: 0, last_id: null },
77+
])
78+
await expect(
79+
backfillNow(session, 'embedding_keyword_tin', { pauseMs: 0 })
80+
).resolves.toMatchObject({ scanned: 3, written: 3, afterId: 'id-3', done: true })
81+
expect(cursors).toEqual(['', 'id-2', 'id-2', 'id-2', 'id-3'])
82+
}
83+
)
84+
85+
it('gives up on a page that times out more than the retry limit in a row', async () => {
86+
const { session, cursors } = sessionOf(
87+
Array.from({ length: PROJECTION_SOURCE_ACL_PAGE_RETRIES + 1 }, () => postgresError('55P03'))
88+
)
89+
await expect(backfillNow(session, 'embedding_keyword_tin', { pauseMs: 0 })).rejects.toThrow(
90+
'SQLSTATE 55P03'
91+
)
92+
expect(cursors).toHaveLength(PROJECTION_SOURCE_ACL_PAGE_RETRIES + 1)
93+
expect(new Set(cursors)).toEqual(new Set(['']))
94+
})
95+
96+
it('propagates an error that is not a timeout without retrying', async () => {
97+
const { session, cursors } = sessionOf([postgresError('42P01')])
98+
await expect(backfillNow(session, 'embedding_search', { pauseMs: 0 })).rejects.toThrow(
99+
'SQLSTATE 42P01'
100+
)
101+
expect(cursors).toEqual([''])
102+
})
103+
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+
125+
it('leaves a page still failing at the budget to the continuation, from the last committed page', async () => {
126+
const { session, cursors } = sessionOf(
127+
[{ scanned: 1, filled: 1, last_id: 'id-1' }, postgresError('57014')],
128+
/** The second page spends the budget before the database cancels it. */
129+
(index) => {
130+
if (index === 1) vi.advanceTimersByTime(1000)
131+
}
132+
)
133+
await expect(
134+
backfillNow(session, 'embedding_search', { pauseMs: 0, budgetMs: 1000 })
135+
).resolves.toMatchObject({ afterId: 'id-1', done: false })
136+
expect(cursors).toEqual(['', 'id-1'])
137+
})
138+
12139
it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])(
13140
'refuses a page size of %s instead of reporting the projection filled',
14141
async (pageSize) => {

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

Lines changed: 101 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { sleep } from '@sim/utils/helpers'
3+
import { backoffWithJitter } from '@sim/utils/retry'
34
import postgres, { type Sql } from 'postgres'
45

56
const logger = createLogger('ProjectionSourceAcl')
@@ -15,12 +16,52 @@ export const PROJECTION_SOURCE_ACL_PAGE_SIZE = 100
1516
export const PROJECTION_SOURCE_ACL_PAGE_PAUSE_MS = 250
1617

1718
/**
18-
* Longest a page may run before the database cancels it; the run then fails and resumes. A caller
19-
* that bounds a run leaves at least this much headroom after its budget, since the budget is
20-
* checked between pages and the page in flight runs to this limit.
19+
* Longest a page may run before the database cancels it; the page is then retried in place. A
20+
* caller that bounds a run leaves at least this much headroom after its budget, plus the longest
21+
* retry pause, since the budget is checked between pages and the page in flight runs to this limit.
2122
*/
2223
export const PROJECTION_SOURCE_ACL_PAGE_TIMEOUT_MS = 60_000
2324

25+
/**
26+
* How many times in a row one page may time out before the run fails. Index maintenance was
27+
* observed holding the page for several minutes; with the pauses below a page waits roughly
28+
* twelve minutes, about fourteen at the jitter's worst, so the budget covers one such pass.
29+
*/
30+
export const PROJECTION_SOURCE_ACL_PAGE_RETRIES = 12
31+
32+
/** Pause before a page is retried: 10 s, doubling to a 60 s base with up to 20% jitter (about 72 s). */
33+
const PAGE_RETRY_PAUSE = { baseMs: 10_000, maxMs: 60_000 } as const
34+
35+
/** The SQLSTATE on a driver error, or on the error it wraps. */
36+
function postgresErrorCode(error: unknown): string | undefined {
37+
if (typeof error !== 'object' || error === null) return undefined
38+
const code = (error as { code?: unknown }).code
39+
if (typeof code === 'string') return code
40+
return postgresErrorCode((error as { cause?: unknown }).cause)
41+
}
42+
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+
2465
/** Pages between progress log lines. */
2566
const PROGRESS_EVERY_PAGES = 100
2667

@@ -117,6 +158,15 @@ export interface ProjectionSourceAclBackfillProgress {
117158
* predicate, the join per candidate every row paid before the columns existed. A run fills the
118159
* range it was given and reports that range done; whether the projection as a whole is done, and
119160
* the analysis the planner then needs, is the caller's, since several runs may share a projection.
161+
*
162+
* A page the database cancels — on a lock timeout, because the keyword index's background
163+
* maintenance holds the index page the row's write needs, or on a statement timeout — is retried
164+
* in place after a pause, up to {@link PROJECTION_SOURCE_ACL_PAGE_RETRIES} times in a row, and
165+
* the cursor stays on the last page that committed. A retry from the run's original cursor would
166+
* instead walk every row the run had filled, past the index entries those writes left behind,
167+
* into a statement timeout of its own; and the maintenance that cancelled the page outlasts the
168+
* few attempts a run gets, so the fill would end where it stalled. A page that is still failing
169+
* when the budget runs out is left to the continuation rather than retried past it.
120170
*/
121171
export async function backfillProjectionSourceAcl(
122172
sql: Sql,
@@ -144,32 +194,56 @@ export async function backfillProjectionSourceAcl(
144194
let written = 0
145195
let pages = 0
146196
let done = false
197+
/** Timeouts in a row on the page after `afterId`; reset once it commits. */
198+
let timeouts = 0
147199
for (;;) {
148-
const page = await sql.begin(async (tx) => {
149-
await tx.unsafe("SET LOCAL lock_timeout = '5s'")
150-
await tx.unsafe(`SET LOCAL statement_timeout = ${PROJECTION_SOURCE_ACL_PAGE_TIMEOUT_MS}`)
151-
const [row] = await tx.unsafe<
152-
Array<{ scanned: number; filled: number; last_id: string | null }>
153-
>(
154-
`WITH page AS (
155-
SELECT s.id, s.document_id, d.connector_id, d.acl
156-
FROM ${projection} s JOIN document d ON d.id = s.document_id
157-
WHERE s.id > $1 AND ($2::text IS NULL OR s.id < $2) AND s.acl IS NULL
158-
ORDER BY s.id LIMIT ${pageSize}
159-
FOR SHARE OF d
160-
), updated AS (
161-
UPDATE ${projection} s SET connector_id = page.connector_id, acl = page.acl
162-
FROM page
163-
WHERE s.id = page.id AND s.document_id = page.document_id AND s.acl IS NULL
164-
RETURNING s.id
200+
let page: { scanned: number; filled: number; last_id: string | null }
201+
try {
202+
page = await sql.begin(async (tx) => {
203+
await tx.unsafe("SET LOCAL lock_timeout = '5s'")
204+
await tx.unsafe(`SET LOCAL statement_timeout = ${PROJECTION_SOURCE_ACL_PAGE_TIMEOUT_MS}`)
205+
const [row] = await tx.unsafe<
206+
Array<{ scanned: number; filled: number; last_id: string | null }>
207+
>(
208+
`WITH page AS (
209+
SELECT s.id, s.document_id, d.connector_id, d.acl
210+
FROM ${projection} s JOIN document d ON d.id = s.document_id
211+
WHERE s.id > $1 AND ($2::text IS NULL OR s.id < $2) AND s.acl IS NULL
212+
ORDER BY s.id LIMIT ${pageSize}
213+
FOR SHARE OF d
214+
), updated AS (
215+
UPDATE ${projection} s SET connector_id = page.connector_id, acl = page.acl
216+
FROM page
217+
WHERE s.id = page.id AND s.document_id = page.document_id AND s.acl IS NULL
218+
RETURNING s.id
219+
)
220+
SELECT (SELECT count(*)::int FROM page) AS scanned,
221+
(SELECT count(*)::int FROM updated) AS filled,
222+
(SELECT max(id) FROM page) AS last_id`,
223+
[afterId, beforeId]
165224
)
166-
SELECT (SELECT count(*)::int FROM page) AS scanned,
167-
(SELECT count(*)::int FROM updated) AS filled,
168-
(SELECT max(id) FROM page) AS last_id`,
169-
[afterId, beforeId]
170-
)
171-
return row
172-
})
225+
return row
226+
})
227+
} catch (error) {
228+
if (!isPageTimeout(error)) throw error
229+
const code = postgresErrorCode(error)
230+
timeouts += 1
231+
if (timeouts > PROJECTION_SOURCE_ACL_PAGE_RETRIES) throw error
232+
if (Date.now() >= deadline) break
233+
const pauseMs = backoffWithJitter(timeouts, null, PAGE_RETRY_PAUSE)
234+
logger.warn('Projection source and ACL backfill page timed out; retrying', {
235+
projection,
236+
afterId,
237+
code,
238+
attempt: timeouts,
239+
retryInMs: Math.round(pauseMs),
240+
})
241+
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
244+
continue
245+
}
246+
timeouts = 0
173247
if (page.last_id === null) {
174248
done = true
175249
break

0 commit comments

Comments
 (0)