npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@assemora/queue-bullmq

v0.2.6

Published

BullMQ queue adapter: durable jobs on Redis (BullMQ inside)

Readme

@assemora/queue-bullmq

The queue behind dispatch() (SPEC.md §82).

const queue = bullQueue({
  connection: { url: process.env.REDIS_URL },
  queue: 'assemora',
})

const app = createApplication({ queue, modules, authorization })

That is the whole of the producer side. dispatch(GenerateSitemap({ pageId })) now writes to Redis instead of running the job in the request that scheduled it, and nothing above this line changes — the adapter implements the QueuePort that @assemora/core declares, and core never learns what Redis is (ADR-0023).

BullMQ and ioredis are declared here and nowhere else in the repository, which pnpm boundaries enforces (SPEC.md §8, §125.1). Neither appears in a signature this package exports: no Job, no Worker, no ConnectionOptions.

The worker is four lines, because core owns the hard part

const app = await createApplication({ queue, modules, authorization }).boot()
const worker = await queue.work({ concurrency: 8 })

process.on('SIGTERM', async () => {
  await worker.stop()
  await app.shutdown()
})

work() resolves once Redis has answered, so a worker that cannot reach the queue fails at startup rather than sitting there consuming nothing. stop() stops taking new jobs and waits for the one in flight — an abandoned job is re-delivered when its lock expires and runs twice.

At least once, and what that costs

A queue delivers at least once. Assemora does not pretend otherwise, and neither should a handler: write every job so that running it twice is the same as running it once.

The duplicate has nothing to do with retries. A worker holds a lock on the job it is running and renews it as it works; a worker that is killed rather than stopped — SIGKILL, an OOM kill, a machine that went away — renews nothing, and the job is invisible to every other worker until that lock expires. Recovery is a scan on the same interval, so the wait is between one and two lock durations:

A hard-killed worker leaves the job it was holding unavailable for 30 to 60 seconds. Then another worker runs it again, from the beginning, even at retries: 0.

retries: 0 means do not try again after a failure. It is not a delivery count, and no setting anywhere makes one.

work({ reclaimAfterMs }) is the lever on the wait:

const worker = await queue.work({ concurrency: 8, reclaimAfterMs: 10_000 })

Lower it and a crashed worker's jobs come back sooner — at the price that a worker which blocks its own event loop for longer than the value has its job taken away and run a second time while the first is still going. Raise it and a crash costs more time before the work is picked up. Neither end removes the duplicate; only an idempotent handler does.

stop() is the case where nothing is lost: it waits for the job in flight, which is exactly why a graceful stop is worth wiring to SIGTERM.

A worker process has to build the application: runJob finds the job by name in the registry the application filled, and re-validates the payload against that declaration. What is left for this package is Redis, retries and the graceful stop.

The port is push and nothing else. work() lives on the adapter rather than on the port because core never pulls — a stage of the mutation path pushes, and a method core would never call is not a seam (ADR-0023).

Retries, and where an exhausted job goes

A job declares retries, and this adapter reads it as how many times to try again after a failure: retries: 3 is four attempts. Between them the delay doubles from retryDelayMs (one second by default) with half of each delay randomised — a hundred jobs that all failed on the same outage must not retry in the same millisecond. It bounds failures and nothing else; a worker that died holding the job is the section above.

Every attempt is reported through the logger, because a queue fails between requests with nobody waiting:

| What happened | Level | Message | | --- | --- | --- | | Failed, attempts left | warn | Job failed, the queue will try again | | Failed, none left | error | Job exhausted its retries and stays in the failed set | | Connection dropped | error | The queue connection failed |

An exhausted job stays in the queue's failed set, with the reason, the attempt count and the request id that scheduled it. It is not deleted and it is not aged out: the work is gone either way, and losing the explanation as well is the failure this package exists to prevent. Read it back without naming BullMQ:

for (const failure of await queue.failed()) {
  console.error(failure.name, failure.requestId, failure.reason)
}

Completed jobs are the opposite case — a receipt, not a record, since the audit log already holds what the job's commands did (SPEC.md §67). They are kept for a day, and at most a thousand of them, so Redis does not grow without a bound nobody set.

Nothing is treated as unretryable, deliberately. BullMQ can fail a job on the first attempt, and it is tempting to do that for a payload the application refuses — but during a rolling deploy an old worker legitimately does not know a new job's name yet, and dead-lettering that would destroy real work to save three cheap attempts.

What a payload may contain

A payload crosses a queue as JSON, so it may hold strings, finite numbers, booleans, null, arrays, plain objects — and dates.

Dates are the one exception, and they are exact rather than lossy: a Date is written as its ISO string, which is what timestamp().toJsonSchema() already declares a timestamp to be on the wire, and runJob re-parses the payload against the job's own input schema on arrival — so a handler that declared timestamp() is handed the Date it declared.

Everything else is refused when the job is pushed, naming the value and its path:

"sitemap.generate" cannot be queued: it carries a Map at payload.settings.seen.

undefined, NaN, Infinity, a BigInt, a function, a Map, a Set, a class instance and a cycle are all refused. Each of them either vanishes or comes back as something else, and discovering that in a worker at three in the morning is the scenario the check exists to remove.

One seam is worth knowing: a job dispatched inside a command is held until the transaction commits, and by then the caller has gone — so core logs a push failure rather than throwing it, and an unqueueable payload there is an error line, not a rejected command. Dispatching outside a command rejects at the call.

The envelope

{ name, payload, retries, requestId, actor?, dispatchedFrom }

The actor and the request id survive Redis, so the job's own commands are authorized as the person whose action scheduled them and the audit log says who. The source does not: a worker runs with source: 'job', because a row it writes was not written by the studio click that scheduled it, and dispatchedFrom is what remains of where the work came from.

Coming back out the envelope is untrusted input like any other — anything holding the connection string can write to that list — so it is parsed against a declared shape before a handler sees it, and a job that is not one fails as MALFORMED_JOB with the offending fields named.

Errors

A failure to reach the queue is an AssemoraError with a code a caller can act on:

| Code | Status | Means | | --- | --- | --- | | QUEUE_UNAVAILABLE | 503 | Not there, or not answering within timeoutMs. Try again | | QUEUE_DENIED | 500 | The credentials were refused. Fix the deployment | | QUEUE_ERROR | 500 | Redis rejected the operation | | UNQUEUEABLE_PAYLOAD | 422 | The payload would not survive JSON | | MALFORMED_JOB | 422 | What came back off the queue is not an envelope |

The connection string never appears in any of them. ioredis quotes the URL it was given in some connection errors, so every message and every cause is put through a redactor built from the adapter's own configuration — knowing the exact secret is what makes that precise rather than hopeful (SPEC.md §85).

timeoutMs (five seconds by default) bounds pushing and starting a worker, because ioredis will otherwise queue a command against a dead connection indefinitely and a request would hang instead of failing. It deliberately does not bound stop(). A timed-out push may still land — nothing un-sends a command already on the wire — which is one more reason a job has to be idempotent: a queue delivers at least once.

Tested against a real Redis

tests/integration/queue-bullmq.test.ts pushes a job, runs a worker, and asserts it ran with the actor, the request id and the source the envelope promised; makes one fail and asserts it retried exactly as often as it asked and then stopped; and stops a worker mid-job to prove the job finished. An adapter tested only against a fake is an adapter nobody has run.

src/queue.test.ts covers the three things that live inside work(), where no caller can see them: that a forged envelope written straight to Redis is refused before any handler sees it, that a worker which cannot reach Redis fails at startup instead of consuming nothing, and — by taking a worker's lock away under it — that a retries: 0 job really is delivered twice.

It skips itself when nothing is reachable at ASSEMORA_TEST_REDIS_URL, and ASSEMORA_REQUIRE_REDIS=1 turns that skip into a failure.

Workspace dependencies

  • @assemora/schema
  • @assemora/core