Skip to main content
Everything that takes longer than a request runs on Ragen’s worker: parsing and embedding an uploaded document, scraping a website, generating a document, re-indexing a rolled-back version, and two nightly maintenance jobs. The web application never does this work itself — it enqueues a job and the worker picks it up. Which engine carries those jobs is a deployment choice.

Choosing a runtime

WORKER_RUNTIME selects the engine. The producers and the worker must agree: an application configured for one engine writes jobs that a worker configured for the other never sees, and the symptom is a document that stays in “processing” forever rather than an error.
The worker already requires REDIS_URL on either runtime — it caches organization settings there. What bullmq adds is that the web application and API need it too: a producer that cannot reach Redis cannot enqueue a job at all. The worker also refuses to start against a Redis configured to evict keys, because an evicting instance can drop queued jobs silently, which looks exactly like work that was never submitted. Set maxmemory-policy noeviction.

Sizing the worker

WORKER_CONCURRENCY sets how many jobs the worker runs at once. It applies to bullmq only; on temporal the equivalent limit is the SDK’s own and this variable does nothing. It counts whole jobs, not steps. One document ingest is a single job made of roughly twenty sequential steps — download, parse, chunk, summarize, mask, embed, store. So WORKER_CONCURRENCY=20 means twenty jobs at a time, not twenty operations.

Document ingest has its own ceiling

Almost every document goes through Docling, and a Docling container converts two documents at a time and holds the rest in memory. So document ingest has a second, lower limit: DOCLING_MAX_CONCURRENCY, 4 by default. It applies to the whole deployment, not to one worker. The worker sets it in Redis, so two replicas at WORKER_CONCURRENCY=20 still run at most four ingests between them, and the rest wait in the queue. Other jobs, such as scraping and document generation, are not affected. Before this ceiling, a burst of uploads sent twenty documents per worker replica to Docling at once. With DOCLING_STRICT=1, the ones Docling could not answer in time failed permanently. Raise it only when Docling can take more: more CPU, and more conversion workers (DOCLING_SERVE_ENG_LOC_NUM_WORKERS). A value around twice Docling’s worker count keeps it busy without queueing inside it. Set it the same on every worker replica: the last worker to start wins. That distinction is the one worth getting right, because it decides what a bulk upload feels like:
.env

What we measured

On 2026-09-16 we ran 416 real ingests of ~500-word documents across both runtimes on one machine, against real embedding, summary and scoring providers. With twenty documents uploaded at once, measured per file from the moment the job was enqueued: Read the last column first: at 10, half the batch spent eighteen seconds waiting for a free slot rather than being processed. That measurement is why the default is 20 — it was 10 until this run compared the two. It predates the ingest ceiling above: the documents were short text files, and today at most DOCLING_MAX_CONCURRENCY of them would be ingesting at once. The parsing and embedding times themselves were identical in all three configurations. The difference is the concurrency ceiling, not the engine — with a single document, bullmq is the faster of the two (6.9s against 10.0s).
These numbers size a deployment; they are not a throughput guarantee. They come from one machine on one afternoon, with short text documents. Your own documents, parser, embedding provider and network will move every figure here. A 200-page PDF parsed by Docling is minutes of work, not seconds.

How to pick a number

  1. Start from the largest burst you expect — a customer’s initial import, a folder re-index, a Google Drive sync.
  2. For document ingest, the number that decides throughput is DOCLING_MAX_CONCURRENCY, and behind it Docling’s own capacity. Raise it together with Docling’s CPU and worker count, not on its own.
  3. Raise WORKER_CONCURRENCY for everything else, then check your model provider’s rate limits. Every concurrent job issues model calls; the ceiling that matters is usually the provider’s, not your worker’s.
  4. Add worker replicas rather than raising the number indefinitely. WORKER_CONCURRENCY is per worker process, so three replicas at 20 give you 60 jobs in flight. Ingest stays at DOCLING_MAX_CONCURRENCY across all three.
The two nightly maintenance jobs are pinned to one at a time regardless of this setting, across every replica, so they cannot overlap.

Durable execution

BullMQ guarantees the message; Temporal guarantees the program. Nothing in Ragen replays, so on bullmq a worker that dies mid-ingest re-runs the whole job from the start rather than resuming at the step it reached. For the eight jobs Ragen runs — all linear chains, the longest capped at fifteen minutes — re-running is cheap, which is why it is the default. If resuming matters to you, WORKER_RUNTIME=temporal is still a supported choice. It lives in webamigos/ragen-enterprise — Apache-2.0, like Ragen itself, and separate because the adapter costs every install that does not want it a gRPC dependency and six packages under a version lock. Three things are worth knowing before you plan around it:
  • You run the server. Temporal is not in docker-compose.yml and not in the Helm chart. TEMPORAL_SERVER_ADDRESS has no fallback in a deployed process, because a fallback that is right on a laptop is silent everywhere else.
  • The worker is an image swap. ragen-enterprise publishes a Dockerfile that is FROM ghcr.io/webamigos/ragen-worker and adds the adapter and the Temporal SDK. Run that instead of the OSS worker image. Setting WORKER_RUNTIME=temporal on the unextended image is not enough: it starts, logs the runtime it resolved, and exits saying the SDK is absent.
  • The producers are a rebuild. web and api enqueue, so they have to speak the same engine — and the images published here register BullMQ alone. There is no image to swap: web is a Next standalone build, which resolves its imports at build time, so it cannot be layered the way the worker can. Build both from source with @ragenai/jobs-temporal added, which is a dependency and two lines per application. docs/durable-execution.md has the procedure. Skip it and the first upload fails with no adapter registered for WORKER_RUNTIME="temporal" — loudly, which is better than a queue nobody reads.
The two nightly schedules are registered by scripts, and on Temporal those need the adapter too, so run them from the enterprise image rather than the OSS one. Both runtimes run the same eight handlers and the same activities — there is no second pipeline — and ragen-enterprise’s CI runs Ragen’s own worker integration suite against a real Temporal server, nightly and on every change, so “supported” means exercised rather than merely declared.

Watching the queues

On bullmq, the worker can serve a queue dashboard showing waiting, active, completed and failed jobs, with each job’s payload and failure stack. It is off unless both credentials are set, because it shows every job’s payload. Do not expose the port publicly.

Switching runtimes on a running deployment

A job already in flight belongs to the engine that accepted it. Changing WORKER_RUNTIME orphans it: the new engine has never heard of it, so its document sits in “processing” until something re-indexes it.
  1. Stop the application, so no new jobs are produced.
  2. Let the worker finish everything it holds — not only documents. A website scrape, a document generation, a re-index and a nightly maintenance run are orphaned by the switch exactly as an ingest is, and only the ingest leaves a visible “processing” state behind. Check the old engine’s own view (the Temporal UI, or the queue dashboard) and wait until nothing is waiting or active on any queue.
  3. Change WORKER_RUNTIME on the worker and on the application.
  4. Start both, and confirm the two nightly schedules exist on the new engine.