Skip to content

Commit 88d10da

Browse files
committed
fix(projects): defer archive repair on database contention
1 parent fb27e1b commit 88d10da

2 files changed

Lines changed: 77 additions & 11 deletions

File tree

‎apps/sim/lib/projects/__integration__/backfill-repair.integration.ts‎

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,16 @@ const environment = {
4646
SOCKET_SERVER_URL: process.env.SOCKET_SERVER_URL,
4747
}
4848
const execute = promisify(execFile)
49-
const run = (command: string) =>
49+
const run = (command: string, artifacts = { manifest, report }) =>
5050
execute(
5151
'bun',
5252
[
5353
'--no-env-file',
5454
'scripts/backfill-projects.ts',
5555
command,
5656
'--manifest',
57-
manifest,
58-
...(command === 'plan' ? [] : ['--report', report, '--ack-release-drained']),
57+
artifacts.manifest,
58+
...(command === 'plan' ? [] : ['--report', artifacts.report, '--ack-release-drained']),
5959
],
6060
{
6161
cwd: new URL('../../../', import.meta.url),
@@ -121,6 +121,61 @@ afterAll(async () => {
121121
})
122122

123123
describe('Operator archive repair against the full compatible schema', () => {
124+
it.each(['workspace', 'project', 'row'] as const)(
125+
'defers a busy %s lock, repairs unrelated environments and resumes without losing progress',
126+
async (lock) => {
127+
const ownerId = `owner-${lock}`
128+
const busyId = `busy-${lock}`
129+
const freeId = `free-${lock}`
130+
const projectId = `project-${lock}`
131+
const artifacts = {
132+
manifest: join(directory, `${lock}-manifest.json`),
133+
report: join(directory, `${lock}-report.json`),
134+
}
135+
const holder = postgres(url.toString(), { max: 1, onnotice: () => {} })
136+
try {
137+
await client`INSERT INTO "user" (id,name,email,email_verified,created_at,updated_at)
138+
VALUES (${ownerId},'Owner',${`${lock}@fixture.test`},true,now(),now())`
139+
await client`INSERT INTO project (id,name,owner_id,archived_at)
140+
VALUES (${projectId},'Archived Project',${ownerId},'2026-05-01')`
141+
await client`INSERT INTO workspace (id,name,owner_id,billed_account_user_id,project_id,archived_at)
142+
VALUES (${busyId},'Busy',${ownerId},${ownerId},${projectId},'2026-05-01'),
143+
(${freeId},'Free',${ownerId},${ownerId},NULL,'2026-05-01')`
144+
await client`INSERT INTO workflow (id,name,user_id,workspace_id,last_synced,created_at,updated_at)
145+
VALUES (${busyId},'Busy flow',${ownerId},${busyId},now(),now(),now()),
146+
(${freeId},'Free flow',${ownerId},${freeId},now(),now(),now())`
147+
await run('plan', artifacts)
148+
await holder.begin(async (tx) => {
149+
if (lock === 'row') await tx`SELECT id FROM workspace WHERE id = ${busyId} FOR SHARE`
150+
else {
151+
const key = lock === 'workspace' ? `project-backfill:${busyId}` : `project:${projectId}`
152+
await tx`SELECT pg_advisory_xact_lock(hashtextextended(${key},0))`
153+
}
154+
await expect(run('repair', artifacts)).rejects.toMatchObject({ code: 2 })
155+
expect(JSON.parse(await readFile(artifacts.report, 'utf8'))).toMatchObject({
156+
status: 'incomplete',
157+
repairsCompleted: [freeId],
158+
})
159+
expect(await client`SELECT workspace_id FROM workflow WHERE archived_at IS NULL`).toEqual(
160+
[{ workspace_id: busyId }]
161+
)
162+
})
163+
await expect(run('repair', artifacts)).rejects.toMatchObject({ code: 2 })
164+
expect(JSON.parse(await readFile(artifacts.report, 'utf8')).repairsCompleted).toEqual(
165+
expect.arrayContaining([freeId, busyId])
166+
)
167+
await run('apply', artifacts)
168+
await run('verify', artifacts)
169+
} finally {
170+
await holder.end({ timeout: 2 })
171+
await client`DELETE FROM workspace WHERE owner_id = ${ownerId}`
172+
await client`DELETE FROM project WHERE owner_id = ${ownerId}`
173+
await client`DELETE FROM "user" WHERE id = ${ownerId}`
174+
}
175+
},
176+
60000
177+
)
178+
124179
it('commits the complete cascade, reports external failure and resumes cleanup from its original manifest', async () => {
125180
await client`INSERT INTO "user" (id,name,email,email_verified,created_at,updated_at) VALUES ('owner','Owner','repair@fixture.test',true,now(),now())`
126181
await client`INSERT INTO workspace (id,name,owner_id,billed_account_user_id,archived_at) VALUES ('env','Archived','owner','owner','2026-05-01')`

‎apps/sim/scripts/backfill-projects.ts‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -331,14 +331,25 @@ Defaults: --batch-size 50 (1–50), --max-batches 100 (1–10000), --seconds 60
331331
if (stop || attempts >= maxBatches || performance.now() - started >= seconds * 1000) break
332332
await assertLock()
333333
attempts++
334-
await repairArchivedProjectEnvironment(sql, repair, progress.runId)
335-
await assertLock()
336-
completedRepairs.add(repair.workspaceId)
337-
await checkpoint()
338-
logger.info('Archive repair completed', {
339-
workspaceId: repair.workspaceId,
340-
workflows: repair.workflowIds.length,
341-
})
334+
try {
335+
await repairArchivedProjectEnvironment(sql, repair, progress.runId)
336+
await assertLock()
337+
completedRepairs.add(repair.workspaceId)
338+
await checkpoint()
339+
logger.info('Archive repair completed', {
340+
workspaceId: repair.workspaceId,
341+
workflows: repair.workflowIds.length,
342+
})
343+
} catch (error) {
344+
const transient = getTransientDatabaseFailure(error)
345+
if (!(error instanceof ProjectBackfillBusy) && !transient) throw error
346+
await assertLock()
347+
logger.warn('Archive repair deferred', {
348+
workspaceId: repair.workspaceId,
349+
failure: transient ?? 'busy',
350+
error: describeError(error),
351+
})
352+
}
342353
await sleep(pauseMs)
343354
}
344355
} else {

0 commit comments

Comments
 (0)