One QueueManager per process
Centralized lifecycle, shutdown, admin stats, and retry defaults.
Background work that can start inline
Background work uses BullMQ over the same Valkey the cache lives on. A single QueueManager owns queues and workers, so request handlers dispatch intent without knowing whether the work runs inline, locally, or in a worker.
BullMQ
job lifecycle
Valkey
queue backend
inline
fallback when queues are off
The mental model:
QueueManager is a process-singleton that owns all queues + workers, so application code only ever talks to one object.When QUEUES_ENABLED=false, every dispatch helper falls back to inline execution. Dev and tests run without a worker process.
sequenceDiagram
participant Producer
participant Manager as QueueManager
participant Valkey
participant Worker
Producer->>Manager: enqueueX(data)
Manager->>Valkey: ZADD with retry config
Valkey-->>Worker: next job
Worker->>Worker: process(data)
alt success
Worker->>Valkey: mark complete (TTL 1h)
else failure
Worker->>Valkey: schedule retry (exp backoff)
Note over Valkey,Worker: up to 5 attempts, then dead-letter
end
Centralized lifecycle, shutdown, admin stats, and retry defaults.
Constants, queue, worker, setup, and types stay together.
When QUEUES_ENABLED=false, dev and tests boot without a worker process.
removeOnComplete keeps one hour or the latest 100 successes.
removeOnFail is false, so the dashboard can show what broke.
The BullMQ lint plugin catches missing failed handlers and retry config.
Every queue is a small directory under src/queues/<name>/:
The reference implementation is email-delivery; it’s the simplest worker that exists (render a template, hand off to the email provider), so it’s a good copy-target.
Application code never imports Queue directly. It calls manager.enqueueX(...). Why:
manager.close() shuts every worker + queue in parallel; signal handlers only know about the manager.getStats() returns waiting/active/completed/failed/delayed/paused counts for every managed queue.A new queue therefore needs four additions to QueueManager: the constructor input, an enqueue<Name>() method, an entry in getStats(), and a close() line. The lint plugin catches workers that omit a failed handler or skip retry config.
BullMQ retries on failure. If a worker dies after writing to the database but before marking the job complete, the same job runs again. Workers must be idempotent. Three patterns:
userId=X, token=Y” is idempotent: re-sending the same token is harmless.INSERT of the same audit row fails on the constraint; the worker treats unique-violation as success.Using BullMQ’s jobId for deduplication only protects against simultaneous duplicate enqueues; not retries.
src/queues/<name>/ following the per-queue directory pattern (constants, types, queue, worker, setup).QueueManager’s constructor, an enqueue<Name>() method, getStats(), and close().manager.enqueue<Name>(...) from the producer.WITH_BULLMQ=1 in the infra stack brings up Bull-board at http://bullmq.localhost; jobs by state, retry timelines, manual retry/discard. Dev-only; not exposed in the prod profile. See Profiles & overlays.
@boring-stack-pkg/eslint-plugin-bullmq catches the common foot-guns: workers that don’t handle failed, jobs that mutate shared state without idempotency, missing retry config.
src/queues/ on GitHub. src/config/setup-queues.ts wires the manager into boot.
sendTemplate() goes through email-delivery when queues are on.