11import { createLogger } from '@sim/logger'
22import { sleep } from '@sim/utils/helpers'
3+ import { backoffWithJitter } from '@sim/utils/retry'
34import postgres , { type Sql } from 'postgres'
45
56const logger = createLogger ( 'ProjectionSourceAcl' )
@@ -15,12 +16,37 @@ export const PROJECTION_SOURCE_ACL_PAGE_SIZE = 100
1516export 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 */
2223export 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. */
2551const 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 */
121156export 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