Skip to content

Commit 01398e0

Browse files
committed
fix(knowledge): retry a projection fill page after an index lock or statement timeout instead of ending the chain
1 parent 32fcc63 commit 01398e0

2 files changed

Lines changed: 186 additions & 29 deletions

File tree

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

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

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

Lines changed: 84 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,37 @@ 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. With the pauses below a page
27+
* waits out a few minutes of index maintenance before giving up.
28+
*/
29+
export const PROJECTION_SOURCE_ACL_PAGE_RETRIES = 6
30+
31+
/** Pause before a page is retried: 10 s, doubling to 30 s, with jitter. */
32+
const PAGE_RETRY_PAUSE = { baseMs: 10_000, maxMs: 30_000 } as const
33+
34+
/**
35+
* The two ways the database cancels a page: `lock_timeout` (55P03) while the page's index write
36+
* waits on a lock the index's background maintenance holds, and `statement_timeout` (57014) when
37+
* the page itself runs past {@link PROJECTION_SOURCE_ACL_PAGE_TIMEOUT_MS}. Both pass once the
38+
* maintenance moves on, so both are retried the same way.
39+
*/
40+
const PAGE_TIMEOUT_CODES: ReadonlySet<string> = new Set(['55P03', '57014'])
41+
42+
/** The SQLSTATE on a driver error, or on the error it wraps. */
43+
function postgresErrorCode(error: unknown): string | undefined {
44+
if (typeof error !== 'object' || error === null) return undefined
45+
const code = (error as { code?: unknown }).code
46+
if (typeof code === 'string') return code
47+
return postgresErrorCode((error as { cause?: unknown }).cause)
48+
}
49+
2450
/** Pages between progress log lines. */
2551
const PROGRESS_EVERY_PAGES = 100
2652

@@ -117,6 +143,15 @@ export interface ProjectionSourceAclBackfillProgress {
117143
* predicate, the join per candidate every row paid before the columns existed. A run fills the
118144
* range it was given and reports that range done; whether the projection as a whole is done, and
119145
* the analysis the planner then needs, is the caller's, since several runs may share a projection.
146+
*
147+
* A page the database cancels — on a lock timeout, because the keyword index's background
148+
* maintenance holds the index page the row's write needs, or on a statement timeout — is retried
149+
* in place after a pause, up to {@link PROJECTION_SOURCE_ACL_PAGE_RETRIES} times in a row, and
150+
* the cursor stays on the last page that committed. A retry from the run's original cursor would
151+
* instead walk every row the run had filled, past the index entries those writes left behind,
152+
* into a statement timeout of its own; and the maintenance that cancelled the page outlasts the
153+
* few attempts a run gets, so the fill would end where it stalled. A page that is still failing
154+
* when the budget runs out is left to the continuation rather than retried past it.
120155
*/
121156
export async function backfillProjectionSourceAcl(
122157
sql: Sql,
@@ -144,32 +179,54 @@ export async function backfillProjectionSourceAcl(
144179
let written = 0
145180
let pages = 0
146181
let done = false
182+
/** Timeouts in a row on the page after `afterId`; reset once it commits. */
183+
let timeouts = 0
147184
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
185+
let page: { scanned: number; filled: number; last_id: string | null }
186+
try {
187+
page = await sql.begin(async (tx) => {
188+
await tx.unsafe("SET LOCAL lock_timeout = '5s'")
189+
await tx.unsafe(`SET LOCAL statement_timeout = ${PROJECTION_SOURCE_ACL_PAGE_TIMEOUT_MS}`)
190+
const [row] = await tx.unsafe<
191+
Array<{ scanned: number; filled: number; last_id: string | null }>
192+
>(
193+
`WITH page AS (
194+
SELECT s.id, s.document_id, d.connector_id, d.acl
195+
FROM ${projection} s JOIN document d ON d.id = s.document_id
196+
WHERE s.id > $1 AND ($2::text IS NULL OR s.id < $2) AND s.acl IS NULL
197+
ORDER BY s.id LIMIT ${pageSize}
198+
FOR SHARE OF d
199+
), updated AS (
200+
UPDATE ${projection} s SET connector_id = page.connector_id, acl = page.acl
201+
FROM page
202+
WHERE s.id = page.id AND s.document_id = page.document_id AND s.acl IS NULL
203+
RETURNING s.id
204+
)
205+
SELECT (SELECT count(*)::int FROM page) AS scanned,
206+
(SELECT count(*)::int FROM updated) AS filled,
207+
(SELECT max(id) FROM page) AS last_id`,
208+
[afterId, beforeId]
165209
)
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-
})
210+
return row
211+
})
212+
} catch (error) {
213+
const code = postgresErrorCode(error)
214+
if (code === undefined || !PAGE_TIMEOUT_CODES.has(code)) throw error
215+
timeouts += 1
216+
if (timeouts > PROJECTION_SOURCE_ACL_PAGE_RETRIES) throw error
217+
if (Date.now() >= deadline) break
218+
const pauseMs = backoffWithJitter(timeouts, null, PAGE_RETRY_PAUSE)
219+
logger.warn('Projection source and ACL backfill page timed out; retrying', {
220+
projection,
221+
afterId,
222+
code,
223+
attempt: timeouts,
224+
retryInMs: Math.round(pauseMs),
225+
})
226+
await sleep(pauseMs)
227+
continue
228+
}
229+
timeouts = 0
173230
if (page.last_id === null) {
174231
done = true
175232
break

0 commit comments

Comments
 (0)