@@ -3,34 +3,62 @@ import type { ScriptMigration } from './types'
33
44export const UNKNOWN_PROVENANCE_REPAIR_BATCH_SIZE = 1000
55
6+ interface RepairPage {
7+ /** Rows still reading `unknown` when the page was selected; zero means the walk is done. */
8+ candidates : number
9+ /** Rows actually returned to untracked. Lower than `candidates` when a writer got there first. */
10+ repaired : number
11+ /** Highest `row_id` in the page, so the next pass resumes past it. */
12+ lastRowId : string | null
13+ }
14+
615/**
7- * Returns one page of `unknown` rows to the untracked state, and reports how many it cleared.
16+ * Returns one page of `unknown` rows to the untracked state.
17+ *
18+ * Both halves belong in one statement. Clearing the marker while a sidecar row survives beside it
19+ * is the state a derived table transformation reads as unknown, so a split repair would be undone
20+ * by the next column operation.
821 *
9- * Both halves are required and belong in one statement. Clearing the marker while a sidecar row
10- * survives beside it is the state a derived table transformation reads as unknown, so a split
11- * repair would be undone by the next column operation.
22+ * The delete re-checks `status` rather than trusting the id the page captured. A provenance-aware
23+ * write commits its exact sidecar and its version marker together, and can land between this
24+ * statement's snapshot and its delete; matching on `row_id` alone would drop that fresh exact
25+ * sidecar and clear the marker behind it, leaving a genuinely secret-bearing row reading as
26+ * legacy — provenance destroyed by the repair meant to make provenance safe. Under READ COMMITTED
27+ * the delete re-evaluates its condition against the updated row, so the writer's row no longer
28+ * matches and is left alone; whichever of the two commits second sees the other's result.
1229 *
1330 * `secret_provenance_version` is not a column the demote trigger watches, so this leaves
1431 * `updated_at` alone and cannot disturb a concurrent write's sidecar binding.
1532 */
16- async function repairUnknownProvenancePage ( sql : Sql , batchSize : number ) : Promise < number > {
17- const repaired = await sql < { id : string } [ ] > `
33+ async function repairUnknownProvenancePage (
34+ sql : Sql ,
35+ batchSize : number ,
36+ afterRowId : string
37+ ) : Promise < RepairPage > {
38+ const [ page ] = await sql < [ RepairPage ] > `
1839 WITH page AS (
1940 SELECT row_id
2041 FROM user_table_row_secret_provenance
21- WHERE status = 'unknown'
42+ WHERE status = 'unknown' AND row_id > ${ afterRowId }
43+ ORDER BY row_id
2244 LIMIT ${ batchSize }
2345 ), cleared AS (
2446 DELETE FROM user_table_row_secret_provenance
2547 WHERE row_id IN (SELECT row_id FROM page)
48+ AND status = 'unknown'
2649 RETURNING row_id
50+ ), marked AS (
51+ UPDATE user_table_rows
52+ SET secret_provenance_version = NULL
53+ WHERE id IN (SELECT row_id FROM cleared)
54+ RETURNING id
2755 )
28- UPDATE user_table_rows
29- SET secret_provenance_version = NULL
30- WHERE id IN (SELECT row_id FROM cleared)
31- RETURNING id
56+ SELECT
57+ (SELECT count(*) FROM page)::int AS "candidates",
58+ (SELECT count(*) FROM marked)::int AS "repaired",
59+ (SELECT max(row_id) FROM page) AS "lastRowId"
3260 `
33- return repaired . length
61+ return page
3462}
3563
3664/**
@@ -56,17 +84,33 @@ async function repairUnknownProvenancePage(sql: Sql, batchSize: number): Promise
5684 * Idempotent and resumable: a repaired row no longer has a sidecar, so it leaves the candidate set
5785 * and a re-run after a crash resumes on what remains. Rows that become unknown after this runs are
5886 * simply left for the writers now instrumented to report them.
87+ *
88+ * Walked by keyset over `row_id` rather than by re-selecting the head of the candidate set. A page
89+ * whose rows were all repaired by a concurrent writer clears nothing, and terminating on "cleared
90+ * nothing" would have ended the walk there and left the rest of the backlog untouched. Advancing
91+ * past the page instead makes each pass finite and the whole walk terminate on the only condition
92+ * that means finished: a page with no candidates left in it.
5993 */
6094export const repairUnknownTableRowProvenance : ScriptMigration = {
6195 name : '0005_repair_unknown_table_row_provenance' ,
6296 async up ( sql : Sql ) : Promise < void > {
6397 let repaired = 0
98+ let skipped = 0
99+ let afterRowId = ''
64100 for ( ; ; ) {
65- const page = await repairUnknownProvenancePage ( sql , UNKNOWN_PROVENANCE_REPAIR_BATCH_SIZE )
66- if ( page === 0 ) break
67- repaired += page
101+ const page = await repairUnknownProvenancePage (
102+ sql ,
103+ UNKNOWN_PROVENANCE_REPAIR_BATCH_SIZE ,
104+ afterRowId
105+ )
106+ if ( page . candidates === 0 || page . lastRowId === null ) break
107+ repaired += page . repaired
108+ skipped += page . candidates - page . repaired
109+ afterRowId = page . lastRowId
68110 console . log ( ` repaired ${ repaired } unknown table row(s)` )
69111 }
70- console . log ( `Unknown table row provenance repair complete: ${ repaired } row(s).` )
112+ console . log (
113+ `Unknown table row provenance repair complete: ${ repaired } row(s) repaired, ${ skipped } left to a concurrent writer.`
114+ )
71115 } ,
72116}
0 commit comments