Skip to content

Commit 454e2e2

Browse files
committed
fix(projects): honor operator cancellation and status exits
1 parent 8c3c334 commit 454e2e2

2 files changed

Lines changed: 100 additions & 4 deletions

File tree

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ async function main() {
131131
From apps/sim: bun --no-env-file scripts/backfill-projects.ts <command> [options]\n
132132
plan Read-only discovery; atomically writes a new --manifest PATH (never overwrites).\nrepair Repair only archived environments listed in --manifest; requires --report PATH.\napply Assign reviewed families from --manifest; requires --report PATH.\nverify Validate the database and repair completion; requires --manifest and --report.\nstatus Read --report; does not connect or resume.\n
133133
Writes require MIGRATION_DATABASE_URL pointing directly to the primary and --ack-release-drained.\nDeploy #8830 and verify old servers/workers drained first. Keep the reviewed manifest and report\nin durable private job storage. Review conflicts and repairs before repair/apply. Repair requires\nthe app runtime environment (DATABASE_URL must target the same database) for provider cleanup.\n
134-
Defaults: --batch-size 50 (1–50), --max-batches 100 (1–10000), --seconds 60 (1–3600), --pause-ms 100 (1–60000).\nA budget limits scheduling. Transactions use 3s statement limits and a 5s total limit on PG17+,\notherwise a 5s idle-transaction limit on PG16. Reuse the same report\nto resume; lost reports can be recreated safely from the same manifest. Changed families need a\nnew plan/report. Complete verify plus deployment evidence is required before #8590 enforcement.\nExit: 0 complete, 2 paused/incomplete, 1 failure. Never run db:migrate to launch this tool.\n`)
134+
Defaults: --batch-size 50 (1–50), --max-batches 100 (1–10000), --seconds 60 (1–3600), --pause-ms 100 (1–60000).\nA budget limits scheduling. Transactions use 3s statement limits and a 5s total limit on PG17+,\notherwise a 5s idle-transaction limit on PG16. Reuse the same report\nto resume; lost reports can be recreated safely from the same manifest. Changed families need a\nnew plan/report. Complete verify plus deployment evidence is required before #8590 enforcement.\nExit: 0 complete, 2 running/paused/incomplete, 1 failure. Never run db:migrate to launch this tool.\n`)
135135
return
136136
}
137137
const [command] = positionals
@@ -146,7 +146,9 @@ Defaults: --batch-size 50 (1–50), --max-batches 100 (1–10000), --seconds 60
146146
throw new Error('Manifest and report must be different files')
147147
if (command === 'status') {
148148
if (!reportPath) throw new Error('status requires --report')
149-
logger.info('Project backfill status', progressSchema.parse(await readJson(reportPath)))
149+
const progress = progressSchema.parse(await readJson(reportPath))
150+
logger.info('Project backfill status', progress)
151+
process.exitCode = progress.status === 'complete' ? 0 : progress.status === 'failed' ? 1 : 2
150152
return
151153
}
152154
const rawUrl = process.env.MIGRATION_DATABASE_URL
@@ -181,8 +183,10 @@ Defaults: --batch-size 50 (1–50), --max-batches 100 (1–10000), --seconds 60
181183
const requestStop = () => {
182184
stop = true
183185
}
184-
process.on('SIGINT', requestStop)
185-
process.on('SIGTERM', requestStop)
186+
if (live) {
187+
process.on('SIGINT', requestStop)
188+
process.on('SIGTERM', requestStop)
189+
}
186190
let lockPid: number | undefined
187191
let repairRuntimeLoaded = false
188192
async function assertLock() {

‎packages/db/scripts/project-contract.integration.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,20 @@ describe('Project expand/backfill/contract against PostgreSQL', () => {
204204
projectsCreated: 50,
205205
status: 'paused',
206206
})
207+
for (const [status, exitCode] of [
208+
['running', 2],
209+
['paused', 2],
210+
['incomplete', 2],
211+
['failed', 1],
212+
['complete', 0],
213+
] as const) {
214+
const checkpoint = JSON.stringify({ ...report, status })
215+
await writeFile(reportPath, checkpoint)
216+
const statusRun = run('status')
217+
if (exitCode === 0) await statusRun
218+
else await expect(statusRun).rejects.toMatchObject({ code: exitCode })
219+
expect(await readFile(reportPath, 'utf8')).toBe(checkpoint)
220+
}
207221
const first =
208222
await sql`SELECT id,project_id FROM workspace WHERE project_id IS NOT NULL ORDER BY id`
209223
await rm(reportPath)
@@ -284,6 +298,84 @@ describe('Project expand/backfill/contract against PostgreSQL', () => {
284298
})
285299
}, 60000)
286300

301+
it.each([
302+
['plan', 'SIGINT'],
303+
['verify', 'SIGTERM'],
304+
] as const)(
305+
'terminates a stalled %s scan on %s',
306+
async (command, signal) => {
307+
await database(async (sql, url) => {
308+
const directory = await mkdtemp(join(tmpdir(), 'project-read-cancel-test-'))
309+
const artifacts = [
310+
'--manifest',
311+
join(directory, 'manifest.json'),
312+
'--report',
313+
join(directory, 'report.json'),
314+
]
315+
const options = {
316+
cwd: new URL('../../../apps/sim/', import.meta.url),
317+
env: { ...process.env, MIGRATION_DATABASE_URL: url },
318+
timeout: 15000,
319+
}
320+
await sql`INSERT INTO workspace (id,name,owner_id) VALUES ('root','Root','owner')`
321+
if (command === 'verify') {
322+
for (const step of ['plan', 'apply'])
323+
await promisify(execFile)(
324+
'bun',
325+
[
326+
'--no-env-file',
327+
'scripts/backfill-projects.ts',
328+
step,
329+
...artifacts,
330+
'--ack-release-drained',
331+
],
332+
options
333+
)
334+
}
335+
const files = await readdir(directory)
336+
const contents = await Promise.all(
337+
files.map((file) => readFile(join(directory, file), 'utf8'))
338+
)
339+
await sql.unsafe(`
340+
ALTER TABLE workspace RENAME TO workspace_fixture_source;
341+
CREATE FUNCTION pause_fixture_scan() RETURNS boolean LANGUAGE plpgsql AS $$
342+
BEGIN PERFORM pg_sleep(5); RETURN true; END $$;
343+
CREATE VIEW workspace AS SELECT * FROM workspace_fixture_source WHERE pause_fixture_scan();
344+
`)
345+
const child = execFile(
346+
'bun',
347+
['--no-env-file', 'scripts/backfill-projects.ts', command, ...artifacts],
348+
options
349+
)
350+
const exited = once(child, 'close')
351+
try {
352+
await expect
353+
.poll(
354+
async () => {
355+
const [state] = await sql`SELECT count(*)::int AS waiting FROM pg_stat_activity
356+
WHERE datname = current_database() AND application_name = 'sim-project-backfill'
357+
AND wait_event = 'PgSleep'`
358+
return state.waiting
359+
},
360+
{ timeout: 5000, interval: 20 }
361+
)
362+
.toBe(1)
363+
child.kill(signal)
364+
expect((await exited)[1]).toBe(signal)
365+
expect(await readdir(directory)).toEqual(files)
366+
expect(
367+
await Promise.all(files.map((file) => readFile(join(directory, file), 'utf8')))
368+
).toEqual(contents)
369+
} finally {
370+
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
371+
await exited
372+
await rm(directory, { recursive: true, force: true })
373+
}
374+
})
375+
},
376+
20000
377+
)
378+
287379
it('refuses a generated plan exceeding the artifact size limit before publishing it', async () => {
288380
await database(async (sql, url) => {
289381
const directory = await mkdtemp(join(tmpdir(), 'project-plan-size-test-'))

0 commit comments

Comments
 (0)