Queues
Background jobs. Typed, retried, durable.
defineJob types a job's payload; createQueue runs it off the request path.
You get exponential-backoff retries, delayed execution, dead-lettering after maxAttempts,
and a polling worker — over a zero-config memory driver or a durable database driver that survives
restarts.
Define a job #
defineJob<Payload>(name, handler) returns a typed definition. The handler receives
the decoded payload and a ctx carrying ctx.attempt (1 on the first run).
import { createQueue, defineJob } from '@apex-stack/core/server'
const email = defineJob<{ to: string }>('email', async (payload, ctx) => {
await sendEmail(payload.to) // ctx.attempt === 1 on the first try
})
Enqueue & process #
Register a job, then enqueue work against it by name. size() reports
pending jobs; process() drains everything due and returns a tally. An enqueued job
whose handler isn't registered is skipped and left pending for a worker that knows it.
const queue = createQueue() // memory driver (default)
queue.register(email)
const id = await queue.enqueue('email', { to: 'a@b.c' })
await queue.size() // → 1 (pending)
await queue.process() // → { processed: 1, done: 1, retried: 0, failed: 0, skipped: 0 }
await queue.size() // → 0
Retries & backoff #
A throwing handler is retried with exponential backoff — rescheduled to
now + backoffBaseMs · 2attempt — and dead-lettered once
attempts reach maxAttempts. A job that isn't due yet simply isn't picked
up. Pass delaySeconds to defer a job, and override maxAttempts per enqueue.
const queue = createQueue({ maxAttempts: 3, backoffBaseMs: 100 })
// Attempt 1 fails → retry at +200ms; attempt 2 fails → +400ms; attempt 3 fails → dead-letter.
await queue.enqueue('email', { to: 'a@b.c' })
await queue.enqueue('email', { to: 'a@b.c' }, { delaySeconds: 5 }) // runs no earlier than +5s
await queue.enqueue('email', { to: 'a@b.c' }, { maxAttempts: 1 }) // per-job override
The clock and id factory are injectable (createQueue({ now, idFactory })), so backoff
schedules, dead-lettering, and delays are all testable without real timers. A dead-lettered job
never runs again, even far in the future.
The worker loop #
work() polls process() on a timer and returns a handle;
stop() ends the loop and clears the interval.
const handle = queue.work({ intervalMs: 1000 }) // drain due jobs every second
// … on shutdown:
handle.stop()
Durable database driver #
The database driver persists jobs so they survive a restart and can be processed by many workers.
Pass a QueueDbHandle (exec/query over ?
placeholders) and run the migration once. Table names are validated as identifiers — no injection.
const queue = createQueue({ driver: 'database', handle, table: 'jobs' })
// Columns: id, name, payload, attempts, max_attempts, run_at, status, last_error
await handle.exec(queue.migrationSql())
queue.register(email)
await queue.enqueue('email', { to: 'a@b.c' })
await queue.process() // same retry/backoff/delay semantics as memory
The server barrel also exports the pieces directly — createQueueMemoryDriver,
createQueueDatabaseDriver, and buildQueueMigrationSql — if you want to wire
the driver up by hand.