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,52 @@ 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. 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. */
2566const 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 */
121171export 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