fix(jobs): honor --dry-run on jobs prune instead of silently deleting (#2712) (#3525)

gbrain jobs prune --dry-run used to silently drop the flag and run the
destructive default — rows were really deleted while the operator
believed they were previewing.

MinionQueue.prune now takes dryRun: count the would-be-pruned rows
without deleting; the CLI parses --dry-run and labels the output.

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Time Attakc
2026-07-28 14:11:47 -07:00
committed by GitHub
co-authored by Garry Tan Claude Opus 5
parent 784358f5fd
commit bd049d2969
3 changed files with 42 additions and 4 deletions
+10 -3
View File
@@ -233,7 +233,7 @@ USAGE
gbrain jobs get <id>
gbrain jobs cancel <id>
gbrain jobs retry <id>
gbrain jobs prune [--older-than 30d]
gbrain jobs prune [--older-than 30d] [--dry-run]
gbrain jobs delete <id>
gbrain jobs stats
gbrain jobs smoke
@@ -633,8 +633,15 @@ HANDLER TYPES (built in)
try { await queue.ensureSchema(); }
catch (e) { console.error(e instanceof Error ? e.message : String(e)); process.exit(1); }
const count = await queue.prune({ olderThan: new Date(Date.now() - days * 86400000) });
console.log(`Pruned ${count} jobs older than ${days} days.`);
// #2712: --dry-run previews the count without deleting. It used to be
// silently ignored (the destructive default ran anyway).
const dryRun = hasFlag(args, '--dry-run');
const count = await queue.prune({ olderThan: new Date(Date.now() - days * 86400000), dryRun });
if (dryRun) {
console.log(`[dry-run] Would prune ${count} jobs older than ${days} days. Nothing deleted.`);
} else {
console.log(`Pruned ${count} jobs older than ${days} days.`);
}
break;
}
+12 -1
View File
@@ -534,10 +534,21 @@ export class MinionQueue {
}
/** Prune old jobs in terminal statuses. Returns count of deleted rows. */
async prune(opts?: { olderThan?: Date; status?: MinionJobStatus[] }): Promise<number> {
async prune(opts?: { olderThan?: Date; status?: MinionJobStatus[]; dryRun?: boolean }): Promise<number> {
const statuses = opts?.status ?? ['completed', 'dead', 'cancelled'];
const olderThan = opts?.olderThan ?? new Date(Date.now() - 30 * 86400000);
// #2712: dryRun counts the would-be-pruned rows without deleting.
// Silent-ignoring a safety flag on a delete path is data loss.
if (opts?.dryRun) {
const rows = await this.engine.executeRaw<{ count: string }>(
`SELECT count(*)::text as count FROM minion_jobs
WHERE status = ANY($1) AND updated_at < $2`,
[statuses, olderThan.toISOString()]
);
return parseInt(rows[0]?.count ?? '0', 10);
}
const rows = await this.engine.executeRaw<{ count: string }>(
`WITH pruned AS (
DELETE FROM minion_jobs
+20
View File
@@ -709,6 +709,26 @@ describe('MinionQueue: Prune', () => {
const count = await queue.prune({ olderThan: new Date(Date.now() + 86400000) }); // future date = prune everything old enough
expect(count).toBe(1); // only the cancelled one
});
// #2712: --dry-run used to be silently ignored — the destructive default
// ran and deleted rows while the operator believed they were previewing.
test('dryRun counts prunable jobs without deleting', async () => {
const job1 = await queue.add('sync', {});
await queue.cancelJob(job1.id); // terminal → prunable
const wouldPrune = await queue.prune({ olderThan: new Date(Date.now() + 86400000), dryRun: true });
expect(wouldPrune).toBe(1);
// The row must still exist after a dry run.
const stillThere = await queue.getJob(job1.id);
expect(stillThere).not.toBeNull();
expect(stillThere!.status).toBe('cancelled');
// A real prune afterwards actually deletes it.
const pruned = await queue.prune({ olderThan: new Date(Date.now() + 86400000) });
expect(pruned).toBe(1);
expect(await queue.getJob(job1.id)).toBeNull();
});
});
// --- Stats (1 test) ---