Skip to content

Commit a8e6233

Browse files
committed
fix(provenance): take the repair's locks in the writer's order
The repair deleted the sidecar and only then updated its parent row, while mutateTableRowsWithSecretProvenance locks user_table_rows up front and upserts the sidecar inside the same transaction. Opposite orders, so an overlapping write deadlocked and Postgres resolved it by aborting either the deployment or somebody's table write. Lock the parent first, in id order, matching lockTableRows. Holding that lock is also what makes the status re-check decisive rather than racy: the writer commits its sidecar and its marker under the same lock, so once it is held the write is either wholly done or has not begun.
1 parent 06f5e76 commit a8e6233

2 files changed

Lines changed: 108 additions & 68 deletions

File tree

packages/db/script-migrations/0005_repair_unknown_table_row_provenance.test.ts

Lines changed: 63 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,76 +5,100 @@ import type { Sql } from 'postgres'
55
import { describe, expect, it, vi } from 'vitest'
66
import { repairUnknownTableRowProvenance } from './0005_repair_unknown_table_row_provenance'
77

8-
interface PageResult {
9-
candidates: number
10-
repaired: number
11-
lastRowId: string | null
12-
}
13-
148
function normalizeSql(value: string): string {
159
return value.replace(/\s+/g, ' ').trim()
1610
}
1711

18-
/** Replays a scripted sequence of pages and records the `afterRowId` each pass asked for. */
19-
function createSqlHarness(pages: PageResult[]): {
12+
/**
13+
* Replays a scripted sequence of candidate pages and records every statement in the order it was
14+
* issued, so a test can assert on lock ordering rather than only on the final counts.
15+
*/
16+
function createSqlHarness(pages: string[][]): {
2017
sql: Sql
21-
cursors: unknown[]
2218
statements: string[]
19+
cursors: unknown[]
2320
} {
24-
const cursors: unknown[] = []
2521
const statements: string[] = []
26-
let call = 0
27-
const query = vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
28-
statements.push(normalizeSql(strings.join('?')))
29-
/** `afterRowId` is interpolated before the page size, so it is the first bound value. */
30-
cursors.push(values[0])
31-
const page = pages[call] ?? { candidates: 0, repaired: 0, lastRowId: null }
32-
call += 1
33-
return Promise.resolve([page])
34-
})
35-
return { sql: query as unknown as Sql, cursors, statements }
22+
const cursors: unknown[] = []
23+
let page = 0
24+
25+
const run = (strings: TemplateStringsArray, ...values: unknown[]) => {
26+
const text = normalizeSql(strings.join('?'))
27+
statements.push(text)
28+
29+
if (text.startsWith('SELECT row_id AS "rowId"')) {
30+
cursors.push(values[0])
31+
const rows = (pages[page] ?? []).map((rowId) => ({ rowId }))
32+
page += 1
33+
return Promise.resolve(rows)
34+
}
35+
if (text.startsWith('DELETE FROM user_table_row_secret_provenance')) {
36+
const ids = (values[0] as string[]) ?? []
37+
return Promise.resolve(ids.map((rowId) => ({ rowId })))
38+
}
39+
if (text.startsWith('UPDATE user_table_rows')) {
40+
const ids = (values[0] as string[]) ?? []
41+
return Promise.resolve(ids.map((id) => ({ id })))
42+
}
43+
return Promise.resolve([])
44+
}
45+
46+
const sql = run as unknown as Sql
47+
sql.begin = vi.fn(async (callback) => (callback as (tx: Sql) => unknown)(sql)) as Sql['begin']
48+
return { sql, statements, cursors }
3649
}
3750

3851
describe('0005 repair unknown table row provenance', () => {
3952
/**
40-
* A provenance-aware write commits its exact sidecar between this statement's snapshot and its
41-
* delete. Matching on the captured id alone would drop that fresh sidecar and clear the marker
42-
* behind it, leaving a secret-bearing row reading as legacy — provenance destroyed by the repair
43-
* meant to make provenance safe. The re-check is what makes the writer's row stop matching.
53+
* `mutateTableRowsWithSecretProvenance` locks `user_table_rows` up front and upserts the sidecar
54+
* inside the same transaction. Touching the sidecar first is the opposite order, and an
55+
* overlapping write would deadlock — Postgres resolving it by aborting either the deployment or
56+
* somebody's table write.
57+
*/
58+
it('locks the parent row before touching the sidecar, in the order writers take them', async () => {
59+
const { sql, statements } = createSqlHarness([['row-1', 'row-2'], []])
60+
61+
await repairUnknownTableRowProvenance.up(sql)
62+
63+
const lockIndex = statements.findIndex((s) => s.includes('FOR UPDATE'))
64+
const deleteIndex = statements.findIndex((s) =>
65+
s.startsWith('DELETE FROM user_table_row_secret_provenance')
66+
)
67+
expect(lockIndex).toBeGreaterThanOrEqual(0)
68+
expect(deleteIndex).toBeGreaterThan(lockIndex)
69+
expect(statements[lockIndex]).toContain('ORDER BY id')
70+
})
71+
72+
/**
73+
* A provenance-aware write commits its exact sidecar and its marker together. Matching on the
74+
* captured id alone would drop that fresh sidecar and clear the marker behind it, leaving a
75+
* secret-bearing row reading as legacy.
4476
*/
4577
it('only deletes sidecars still reading unknown', async () => {
46-
const { sql, statements } = createSqlHarness([
47-
{ candidates: 1, repaired: 1, lastRowId: 'row-1' },
48-
{ candidates: 0, repaired: 0, lastRowId: null },
49-
])
78+
const { sql, statements } = createSqlHarness([['row-1'], []])
5079

5180
await repairUnknownTableRowProvenance.up(sql)
5281

53-
expect(statements[0]).toContain('DELETE FROM user_table_row_secret_provenance')
54-
expect(statements[0]).toContain("AND status = 'unknown'")
82+
const deleteStatement = statements.find((s) =>
83+
s.startsWith('DELETE FROM user_table_row_secret_provenance')
84+
)
85+
expect(deleteStatement).toContain("AND status = 'unknown'")
5586
})
5687

5788
/**
5889
* A page whose rows were all repaired by a concurrent writer clears nothing. Stopping there would
5990
* have ended the walk and left the rest of the backlog untouched.
6091
*/
6192
it('keeps walking past a page a concurrent writer already repaired', async () => {
62-
const { sql, cursors } = createSqlHarness([
63-
{ candidates: 2, repaired: 0, lastRowId: 'row-2' },
64-
{ candidates: 1, repaired: 1, lastRowId: 'row-9' },
65-
{ candidates: 0, repaired: 0, lastRowId: null },
66-
])
93+
const { sql, cursors } = createSqlHarness([['row-1', 'row-2'], ['row-9'], []])
6794

6895
await repairUnknownTableRowProvenance.up(sql)
6996

7097
expect(cursors).toEqual(['', 'row-2', 'row-9'])
7198
})
7299

73100
it('stops on the first page with no candidates left', async () => {
74-
const { sql, cursors } = createSqlHarness([
75-
{ candidates: 1, repaired: 1, lastRowId: 'row-1' },
76-
{ candidates: 0, repaired: 0, lastRowId: null },
77-
])
101+
const { sql, cursors } = createSqlHarness([['row-1'], []])
78102

79103
await repairUnknownTableRowProvenance.up(sql)
80104

packages/db/script-migrations/0005_repair_unknown_table_row_provenance.ts

Lines changed: 45 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,19 @@ interface RepairPage {
1515
/**
1616
* Returns one page of `unknown` rows to the untracked state.
1717
*
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.
18+
* Takes the parent row lock before touching the sidecar, in `id` order, because that is the order
19+
* `mutateTableRowsWithSecretProvenance` takes them: it locks `user_table_rows` up front, then
20+
* upserts the sidecar inside the same transaction. Deleting the sidecar first and only then
21+
* updating the parent is the opposite order, so an overlapping write would deadlock — and Postgres
22+
* would resolve it by aborting either the deployment or somebody's table write. Sharing the
23+
* writer's order means the two serialize instead.
2124
*
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.
25+
* Holding the parent lock is also what makes the status re-check below decisive rather than
26+
* racy: a provenance-aware write commits its exact sidecar and its version marker together under
27+
* that same lock, so once it is held the write is either wholly done or has not begun. Matching on
28+
* the id alone would drop a freshly exact sidecar and clear the marker behind it, leaving a
29+
* genuinely secret-bearing row reading as legacy — provenance destroyed by the repair meant to make
30+
* provenance safe.
2931
*
3032
* `secret_provenance_version` is not a column the demote trigger watches, so this leaves
3133
* `updated_at` alone and cannot disturb a concurrent write's sidecar binding.
@@ -35,30 +37,44 @@ async function repairUnknownProvenancePage(
3537
batchSize: number,
3638
afterRowId: string
3739
): Promise<RepairPage> {
38-
const [page] = await sql<[RepairPage]>`
39-
WITH page AS (
40-
SELECT row_id
41-
FROM user_table_row_secret_provenance
42-
WHERE status = 'unknown' AND row_id > ${afterRowId}
43-
ORDER BY row_id
44-
LIMIT ${batchSize}
45-
), cleared AS (
40+
const candidates = await sql<{ rowId: string }[]>`
41+
SELECT row_id AS "rowId"
42+
FROM user_table_row_secret_provenance
43+
WHERE status = 'unknown' AND row_id > ${afterRowId}
44+
ORDER BY row_id
45+
LIMIT ${batchSize}
46+
`
47+
if (candidates.length === 0) return { candidates: 0, repaired: 0, lastRowId: null }
48+
const rowIds = candidates.map((candidate) => candidate.rowId)
49+
50+
const repaired = await sql.begin(async (tx) => {
51+
await tx`
52+
SELECT id FROM user_table_rows
53+
WHERE id = ANY(${rowIds}::text[])
54+
ORDER BY id
55+
FOR UPDATE
56+
`
57+
const cleared = await tx<{ rowId: string }[]>`
4658
DELETE FROM user_table_row_secret_provenance
47-
WHERE row_id IN (SELECT row_id FROM page)
59+
WHERE row_id = ANY(${rowIds}::text[])
4860
AND status = 'unknown'
49-
RETURNING row_id
50-
), marked AS (
61+
RETURNING row_id AS "rowId"
62+
`
63+
if (cleared.length === 0) return 0
64+
const marked = await tx<{ id: string }[]>`
5165
UPDATE user_table_rows
5266
SET secret_provenance_version = NULL
53-
WHERE id IN (SELECT row_id FROM cleared)
67+
WHERE id = ANY(${cleared.map((row) => row.rowId)}::text[])
5468
RETURNING id
55-
)
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"
60-
`
61-
return page
69+
`
70+
return marked.length
71+
})
72+
73+
return {
74+
candidates: rowIds.length,
75+
repaired: repaired as number,
76+
lastRowId: rowIds[rowIds.length - 1],
77+
}
6278
}
6379

6480
/**

0 commit comments

Comments
 (0)