···8383CRON_SECRET=<base64url-encoded 32 bytes>
84848585# Optional: per-invocation worker time budget in milliseconds.
8686-# Default 25_000. Set lower in dev so `pnpm jobs:tick` returns sooner when
8787-# the queue is empty.
8686+# Default 270_000 (just under the 300s Vercel maxDuration). Set lower in dev
8787+# so `pnpm jobs:tick` returns sooner when the queue is empty.
8888# NUXT_WORKER_BUDGET_MS=5000
8989+9090+# Optional: how many jobs the worker runs concurrently per invocation.
9191+# Default 6. Jobs are network-bound (SSH to the knot, HTTPS to GitHub), so
9292+# concurrency raises throughput without CPU contention.
9393+# NUXT_WORKER_CONCURRENCY=6
···33import { claim, complete, fail } from '#server/utils/queue'
4455const LEASE_MS = 5 * 60_000 // 5 min — generous for a sync job
66-const DEFAULT_BUDGET_MS = 25_000 // leave headroom under Vercel's 10s default; pro tiers can override
66+77+// Use most of Vercel's 300s `maxDuration` (see nuxt.config `functions`). The
88+// old 25s budget drained only a handful of jobs per minute, so the queue grew
99+// unboundedly under real push volume. Leave headroom for the response and for
1010+// in-flight jobs to settle.
1111+const DEFAULT_BUDGET_MS = 270_000
1212+1313+// How many jobs to run at once inside one invocation. Each job is mostly
1414+// network wait (SSH to the knot, HTTPS to GitHub), so concurrency buys real
1515+// throughput without CPU contention. A single slow/hung job no longer blocks
1616+// the others for the whole budget.
1717+const DEFAULT_CONCURRENCY = 6
1818+1919+// Hard cap on a single job's wall-clock time. A knot SSH connection can hang
2020+// past its `readyTimeout`; without this the slot stays busy until the budget
2121+// ends. On timeout the job is recorded as a failure so backoff and the attempt
2222+// ceiling apply, and the slot is freed for the next job.
2323+const JOB_TIMEOUT_MS = 45_000
2424+2525+class JobTimeoutError extends Error {
2626+ constructor(ms: number) {
2727+ super(`job exceeded ${ms}ms wall-clock cap`)
2828+ this.name = 'JobTimeoutError'
2929+ }
3030+}
3131+3232+async function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
3333+ let timer: ReturnType<typeof setTimeout> | undefined
3434+ try {
3535+ return await Promise.race([
3636+ p,
3737+ new Promise<never>((_, reject) => {
3838+ timer = setTimeout(() => reject(new JobTimeoutError(ms)), ms)
3939+ }),
4040+ ])
4141+ }
4242+ finally {
4343+ if (timer) clearTimeout(timer)
4444+ }
4545+}
746847export default defineEventHandler(async event => {
948 const cronSecret = process.env.CRON_SECRET
···1655 throw createError({ statusCode: 401, statusMessage: 'unauthorized' })
1756 }
18571919- const workerId = `${process.env.VERCEL_DEPLOYMENT_ID ?? 'local'}:${crypto.randomUUID()}`
2020- const budgetMs = Number(useRuntimeConfig().workerBudgetMs) || DEFAULT_BUDGET_MS
5858+ const config = useRuntimeConfig()
5959+ const budgetMs = Number(config.workerBudgetMs) || DEFAULT_BUDGET_MS
6060+ const concurrency = Number(config.workerConcurrency) || DEFAULT_CONCURRENCY
2161 const deadline = Date.now() + budgetMs
22622363 let processed = 0
6464+ let failed = 0
2465 let drained = false
25662626- // Sequential by design: each iteration claims one job, runs it, records the
2727- // outcome. We don't parallelise because each Vercel invocation is a single
2828- // small worker; concurrency comes from cron firing multiple invocations.
2929- // eslint-disable-next-line no-await-in-loop
3030- while (Date.now() < deadline) {
3131- // eslint-disable-next-line no-await-in-loop
3232- const job = await claim(workerId, LEASE_MS)
3333- if (!job) {
3434- drained = true
3535- break
3636- }
6767+ // Each lane runs the claim -> dispatch -> record loop independently until the
6868+ // queue drains or the budget runs out. `claim()` is an atomic
6969+ // `FOR UPDATE SKIP LOCKED`, so lanes never race for the same row; concurrency
7070+ // comes from lanes waiting on different jobs' network I/O at the same time.
7171+ async function lane() {
7272+ const workerId = `${process.env.VERCEL_DEPLOYMENT_ID ?? 'local'}:${crypto.randomUUID()}`
7373+ while (Date.now() < deadline) {
7474+ // eslint-disable-next-line no-await-in-loop -- each iteration processes one job to completion
7575+ const job = await claim(workerId, LEASE_MS)
7676+ if (!job) {
7777+ drained = true
7878+ return
7979+ }
8080+8181+ try {
8282+ // eslint-disable-next-line no-await-in-loop
8383+ await withTimeout(dispatch(job), JOB_TIMEOUT_MS)
8484+ // eslint-disable-next-line no-await-in-loop
8585+ await complete(job.id)
8686+ }
8787+ catch (err) {
8888+ failed++
8989+ // eslint-disable-next-line no-await-in-loop
9090+ await fail(job.id, job.attempts, err)
9191+ }
37923838- try {
3939- // eslint-disable-next-line no-await-in-loop
4040- await dispatch(job)
4141- // eslint-disable-next-line no-await-in-loop
4242- await complete(job.id)
4343- }
4444- catch (err) {
4545- // eslint-disable-next-line no-await-in-loop
4646- await fail(job.id, job.attempts, err)
9393+ processed++
4794 }
4848-4949- processed++
5095 }
51965252- return { ok: true, processed, drained, workerId }
9797+ await Promise.all(Array.from({ length: concurrency }, () => lane()))
9898+9999+ return { ok: true, processed, failed, drained, concurrency }
53100})
···1515}
16161717/**
1818+ * Hard ceiling on delivery attempts. A job that reaches this many attempts is
1919+ * marked `failed` rather than re-leased. This is the backstop for jobs whose
2020+ * work outlives the worker budget: those return without ever reaching `fail()`
2121+ * (which is what enforces the soft `maxAttempts` backoff), so the row stays
2222+ * `running` with an expired lease and would otherwise be re-claimed forever.
2323+ * `claim()` enforces this ceiling before handing a job back out.
2424+ */
2525+export const MAX_ATTEMPTS = 8
2626+2727+/**
1828 * Push a job onto the queue. `payload` must be a JSON-serialisable object — keep
1929 * it small (an envelope of identifiers, not a webhook body); see PLAN.md.
2030 */
···4252 // Two conditions for a job to be claimable:
4353 // 1. status='queued' AND run_after <= now()
4454 // 2. status='running' AND locked_until < now() (lease expired)
5555+ // Retire jobs that have burned through the attempt ceiling. Without this an
5656+ // expired-lease `running` job (its work timed out past the worker budget, so
5757+ // `fail()` never ran) would be re-claimed indefinitely, climbing to dozens of
5858+ // attempts while blocking the head of the queue. Do it in the same query as
5959+ // the claim so a single worker tick makes forward progress even when the head
6060+ // of the queue is all poison.
6161+ await db.execute(sql`
6262+ UPDATE ${job}
6363+ SET status = 'failed', locked_by = null, locked_until = null, updated_at = now()
6464+ WHERE ${job.status} = 'running'
6565+ AND ${job.lockedUntil} < now()
6666+ AND ${job.attempts} >= ${MAX_ATTEMPTS}
6767+ `)
6868+4569 const result = await db.execute<JobEnvelope>(sql`
4670 UPDATE ${job}
4771 SET
···5579 WHERE
5680 (${job.status} = 'queued' AND ${job.runAfter} <= now())
5781 OR
5858- (${job.status} = 'running' AND ${job.lockedUntil} < now())
8282+ (${job.status} = 'running' AND ${job.lockedUntil} < now() AND ${job.attempts} < ${MAX_ATTEMPTS})
5983 ORDER BY ${job.runAfter} ASC
6084 FOR UPDATE SKIP LOCKED
6185 LIMIT 1
···84108 * Record a failure. Re-queues with exponential backoff until `maxAttempts`,
85109 * after which the job is marked `failed` and stays put for inspection.
86110 */
8787-export async function fail(id: number, attempts: number, err: unknown, maxAttempts = 5) {
111111+export async function fail(id: number, attempts: number, err: unknown, maxAttempts = MAX_ATTEMPTS) {
88112 const db = useDb()
89113 const message = err instanceof Error ? err.message : String(err)
90114
+28-1
test/unit/queue.spec.ts
···22import { afterEach, beforeEach, describe, expect, it } from 'vitest'
33import { job } from '../../server/db/schema'
44import { clearDb, setDb, useDb } from '../../server/utils/db'
55-import { claim, complete, enqueue, fail } from '../../server/utils/queue'
55+import { claim, complete, enqueue, fail, MAX_ATTEMPTS } from '../../server/utils/queue'
66import { createTestDb } from '../utils/db'
7788describe('queue', () => {
···8383 expect(rows[0]?.status).toBe('queued')
8484 expect(rows[0]?.lastError).toBe('boom')
8585 expect(new Date(rows[0]?.runAfter ?? 0).getTime()).toBeGreaterThan(Date.now())
8686+ })
8787+8888+ it('retires a stuck running job once it reaches the attempt ceiling', async () => {
8989+ await enqueue('github.push', {})
9090+ const db = useDb()
9191+9292+ // Drive the job to the ceiling by expiring its lease and re-claiming, as
9393+ // happens when a job's work times out past the worker budget without ever
9494+ // reaching fail().
9595+ let last: Awaited<ReturnType<typeof claim>> = null
9696+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
9797+ // eslint-disable-next-line no-await-in-loop
9898+ last = await claim('worker', 60_000)
9999+ expect(last).not.toBeNull()
100100+ // eslint-disable-next-line no-await-in-loop
101101+ await db.execute(sql`UPDATE ${job} SET locked_until = now() - interval '1 minute'`)
102102+ }
103103+ expect(last?.attempts).toBe(MAX_ATTEMPTS)
104104+105105+ // The next claim must not hand the poison job back out; instead it retires
106106+ // it to `failed` and returns null (queue otherwise empty).
107107+ const next = await claim('worker', 60_000)
108108+ expect(next).toBeNull()
109109+110110+ const rows = await db.select().from(job)
111111+ expect(rows[0]?.status).toBe('failed')
112112+ expect(rows[0]?.lockedBy).toBeNull()
86113 })
8711488115 it('marks failed once attempts >= maxAttempts', async () => {