···11import crypto from 'node:crypto'
22import { dispatch } from '#server/utils/job-handlers'
33-import { claim, complete, fail } from '#server/utils/queue'
33+import { claim, cleanupOldJobs, complete, fail } from '#server/utils/queue'
4455const LEASE_MS = 5 * 60_000 // 5 min — generous for a sync job
66···6060 const concurrency = Number(config.workerConcurrency) || DEFAULT_CONCURRENCY
6161 const deadline = Date.now() + budgetMs
62626363+ // Opportunistic pruning of old terminal jobs. Cheap, and keeps the table
6464+ // from growing without bound (which would slow claim() and the dashboard's
6565+ // per-repo job aggregation). Failures here must not block draining.
6666+ const pruned = await cleanupOldJobs().catch(() => 0)
6767+6368 let processed = 0
6469 let failed = 0
6570 let drained = false
···9610197102 await Promise.all(Array.from({ length: concurrency }, () => lane()))
981039999- return { ok: true, processed, failed, drained, concurrency }
104104+ return { ok: true, processed, failed, pruned, drained, concurrency }
100105})
+25
server/utils/queue.ts
···104104 .where(sql`${job.id} = ${id}`)
105105}
106106107107+/** Terminal jobs older than this are pruned. `failed` rows are kept longer so
108108+ * there's a window to inspect them before they age out. */
109109+const DONE_RETENTION_MS = 24 * 60 * 60_000 // 1 day
110110+const FAILED_RETENTION_MS = 7 * 24 * 60 * 60_000 // 7 days
111111+112112+/**
113113+ * Delete old terminal jobs so the table stays small. Unbounded growth slows
114114+ * both the `claim()` scan and the dashboard's per-repo job aggregation (which
115115+ * has no index on the payload's repo id). Returns the number of rows removed.
116116+ * Safe to call opportunistically from the worker; it only touches rows past
117117+ * their retention window.
118118+ */
119119+export async function cleanupOldJobs(): Promise<number> {
120120+ const db = useDb()
121121+ const doneCutoff = new Date(Date.now() - DONE_RETENTION_MS)
122122+ const failedCutoff = new Date(Date.now() - FAILED_RETENTION_MS)
123123+ const result = await db.execute(sql`
124124+ DELETE FROM ${job}
125125+ WHERE (${job.status} = 'done' AND ${job.updatedAt} < ${doneCutoff.toISOString()})
126126+ OR (${job.status} = 'failed' AND ${job.updatedAt} < ${failedCutoff.toISOString()})
127127+ `)
128128+ const affected = (result as { rowCount?: number, affectedRows?: number })
129129+ return affected.rowCount ?? affected.affectedRows ?? 0
130130+}
131131+107132/**
108133 * Record a failure. Re-queues with exponential backoff until `maxAttempts`,
109134 * after which the job is marked `failed` and stays put for inspection.