Step Queue
Workers, Concurrency & Progress
How QueueSystem starts a BullMQ worker, tuning concurrency, listening to worker events, and reporting step progress.
defineProcessor is what actually starts a BullMQ Worker: everything before it (defineInputSchema, defineConnection, defineConcurrency) only configures what that worker will be.
Starting the worker
const myQueue = new QueueSystem(NameFactory({ name: "MyCoolQueue", env: "dev" }))
.defineInputSchema(z.object({ greeting: z.string() }))
.defineConnection({ host: "localhost", port: 6379 })
.defineProcessor((processor) =>
processor.addStep({
name: "greet",
handler: async (job) => ({ shout: job.data.greeting.toUpperCase() }),
}),
);
defineProcessor requires a connected system: defineConnection must run first. This is enforced at the type level: calling defineProcessor before defineConnection is a compile error (this parameter requires TConnected to be true), with a matching runtime guard for JavaScript callers or code that casts past the type.
defineProcessor also narrows worker from Worker | undefined to Worker, and queue was already narrowed to Queue by defineConnection:
const pending = new QueueSystem(NameFactory({ name: "MyCoolQueue" }));
pending.queue; // Queue | undefined
pending.worker; // Worker | undefined
const connected = pending.defineConnection({ host: "localhost", port: 6379 });
connected.queue; // Queue
connected.worker; // Worker | undefined — not started yet
const running = connected.defineProcessor((step) =>
step.addStep({ name: "greet", handler: async () => ({ ok: true }) }),
);
running.queue; // Queue
running.worker; // Worker
Concurrency
defineConcurrency(n) sets how many jobs the worker processes at once. It defaults to 1, and must be called before defineProcessor (the value it sets is read when the Worker is constructed):
const myQueue = new QueueSystem(NameFactory({ name: "MyCoolQueue" }))
.defineConnection(connection)
.defineConcurrency(10)
.defineProcessor((processor) =>
processor.addStep({ name: "work", handler: async () => ({}) }),
);
Listening to worker events
addWorkerEventListener / removeWorkerEventListener are typed wrappers around the underlying Worker’s event emitter: no any in the callback signature.
importer.addWorkerEventListener("progress", (_job, progress) => {
console.log("[progress]", progress);
});
importer.addWorkerEventListener("failed", (job, err) => {
console.error(`job ${job?.id} failed:`, err.message);
});
addWorkerEventListener throws if called before defineProcessor has created the worker.
progress.steps: per-step status for free
Every job’s BullMQ progress carries a steps object, maintained automatically — no configuration needed. Each key is a step’s name; each value is one of:
"not_started"— the step hasn’t run yet"running"— the step’s handler is currently executing (or, for a step with achildQueue, waiting on its children)"Finished in <N>ms"— the step completed successfully"Skipped after <N>ms"—job.actionErrors.skip/skipToFinalbypassed it"Failed after <N>ms"— the handler threw and the job is failing"Recovering (attempt <n>/<max>)"— aRecoverWithChildErrorchild job is in flight for this step
The final step (implicit or explicit) is reported under the key "final".
const job = await importer.addJob({ rows: 100 });
// while it's running:
const inFlight = await importer.queue.getJob(job.id);
inFlight?.progress;
// { step: "import", progress: 0, steps: { import: "running", final: "not_started" } }
This survives a job pausing in waiting-children or recovering via a child job: the status and elapsed time recorded before the pause are read back from the job’s persisted progress and carried forward, not reset.
Reporting progress from a step
There are two other, independent ways to surface progress from a step:
job.updateStepProgress({...}), called from inside a handler, merges custom data into the job’s BullMQ progress and emits a"progress"event on the worker.- A
progressReportcallback on a step, invoked automatically at"start","finish","skip", and (for a step with achildQueue)"child-finish", useful for logging or pushing updates without cluttering the handler itself.
const importer = new QueueSystem(NameFactory({ name: "importer", env: "example" }))
.defineInputSchema(z.object({ rows: z.number() }))
.defineConnection(connection)
.defineProcessor((step) =>
step
.addStep({
name: "import",
progressReport: async (_job, _token, stepName, status) => {
console.log(`[step] ${stepName} -> ${status}`);
},
handler: async (job) => {
for (let done = 0; done <= job.data.rows; done += 25) {
const percent = Math.round((done / job.data.rows) * 100);
await job.updateStepProgress({ imported: done, percent });
}
return { imported: job.data.rows };
},
})
.addFinalStep({ handler: async (job) => ({ imported: job.data.imported }) }),
);
// Typed worker event listener — no `any`.
importer.addWorkerEventListener("progress", (_job, progress) => {
console.log("[progress]", progress);
});
Step lifecycle logging
ProcessorBuilder’s debug() method opts a processor in to step lifecycle logging (start, finish, rollback, and similar transitions) via BullMQ’s own job.log. It’s off by default:
processor.debug().addStep({ name: "greet", handler: async () => ({}) });
// pass false to explicitly disable it again
processor.debug(false);
Closing a queue system
close() gracefully closes the worker, the queue, and any QueueEvents instance that was created:
await myQueue.close();
Call it during process shutdown, or between tests, to avoid leaking Redis connections.