BullMQ
createBullMQDriver is a driver of the mail queue over BullMQ, on Redis or Valkey. The package depends on neither: you pass in BullMQ's Queue and Worker classes.
const { Queue, Worker } = require('bullmq');
const { createMailQueue } = require('@alexify/mailer/queue');
const { createBullMQDriver } = require('@alexify/mailer/bullmq');
const driver = createBullMQDriver({
bullmq: { Queue, Worker },
connection: { host: 'valkey', port: 6379 },
prefix: 'app',
});
const queue = createMailQueue({ driver });A process that only enqueues passes { Queue }, or a queue of its own ({ Queue: myQueue }), which the driver does not close.
The jobs it adds
| BullMQ option | Set to |
|---|---|
jobId | the id of the job: an add again under it keeps the first job |
attempts | the attempts of the job (5 by default) |
backoff | { type: 'alexify-mailer' }: the delay the queue chose |
priority | high 1, normal 2, low 3 (priorities); never 0, which BullMQ runs before every prioritized job |
delay | until scheduledAt |
removeOnComplete, removeOnFail | dedupeWindowSeconds (a day) and retainFailedSeconds (a week), by time, as on every driver (jobOptions wins) |
A duplicate is told by reading the job back (detectDuplicates, one read per enqueue; 'bulk' for enqueueMany too, 32 reads at once). It holds while BullMQ keeps the job, so the retention is by time: one by count shrinks the window of duplicates as the queue gets busy. deduplication of BullMQ is not used, as it holds only while the first job is not finished. A completed job keeps only { v, hash } of its data (job.updateData, by retainData: 'failed', the default; 'all' or 'none'), so its message does not wait a day in Redis.
A rate limit of a tenant's scope (rateLimitScope: 'tenant') is a retry of the job on BullMQ, which pauses a queue only whole; and tenants share a queue fairly only in BullMQ Pro (groups): give each tenant, or each stream (transactional and bulk), a queue and workers of its own:
const transactional = createMailQueue({ driver: createBullMQDriver({ bullmq, queueName: 'mail-transactional' }) });
const bulk = createMailQueue({ driver: createBullMQDriver({ bullmq, queueName: 'mail-bulk' }) });A job BullMQ fails itself, which the handler never settled (stalled more than maxStalledCount), is logged as queue.job.lost and told to the queue's audit sink as delivery_unknown (code queue.lost): its run may have sent it before it stalled.
Its jobs by state, the failed ones and a retry of one by hand are BullMQ's own (queue.getJobCounts(), queue.getFailed(), job.retry()), and a dashboard such as Bull Board reads them.
The workers it makes
queue.createWorker() makes a BullMQ Worker with your workerOptions (lockDuration, maxStalledCount, limiter…) and a backoff strategy that reads the delay of each retry. Each outcome of a run is what BullMQ takes:
| Outcome | To BullMQ | Its attempt |
|---|---|---|
| ack | the job's return value | spent, completed |
| retry | an error, its delay read by the strategy | spent |
retry of the queue (rateLimitScope: 'queue', with limiter) | queue.rateLimit(ms), then RateLimitError | not spent |
| fail | UnrecoverableError, its reason redacted | spent, failed |
| release | moveToDelayed or moveToWait, then DelayedError or WaitingError | not spent |
BullMQ reads these errors by their name, so the package imports nothing of it. The processor takes BullMQ's signal (from BullMQ 5.7x) and its own: on close({ timeoutMilliseconds }), the jobs still running past the timeout are aborted, and a job not yet sent goes back.
The driver logs what BullMQ does on its own: a job it failed itself, such as one stalled more than maxStalledCount times, which the handler never saw (queue.job.lost), a stall, and the errors of the worker.
A Worker of your own
const { Worker } = require('bullmq');
const { createMailJobHandler } = require('@alexify/mailer/queue');
const { createBullMQBackoffStrategy, createBullMQProcessor } = require('@alexify/mailer/bullmq');
const processor = createBullMQProcessor(createMailJobHandler({ transport, renderer }));
new Worker('mail', processor, {
connection,
settings: { backoffStrategy: createBullMQBackoffStrategy(myOtherStrategy) },
});Without that strategy, BullMQ cannot retry a mail job: it fails with "Unknown backoff strategy alexify-mailer".
Notes
- Per-tenant fairness (groups) is a feature of BullMQ Pro, not of BullMQ.
- BullMQ wants Redis or Valkey with
maxmemory-policy noeviction. - The data of a job is in Redis as it is, a token of a reset link among it: use Redis ACLs and TLS.
pnpm test:bullmqruns the driver on BullMQ and Valkey (tests/bullmq/docker-compose.yml).