Workers
Publishing a job only enqueues it. This is the entrypoint you write to drain the queue, in a long-running process, a serverless tick, or a pushed batch.
QUESTPIE does not start a worker for you. You write a small entrypoint that imports the built app and calls one of three methods. Which one you can call depends on the adapter, and calling the wrong one throws.
| Model | Call | Works on |
|---|---|---|
| Long-running process | app.queue.listen() | pg-boss, BullMQ |
| One batch, then exit | app.queue.runOnce() | pg-boss, BullMQ |
| Pushed batches | app.queue.createPushConsumer() | Cloudflare Queues |
A long-running worker
Write a file, run it as its own process.
import { app } from "#questpie";
await app.queue.listen({ teamSize: 5, batchSize: 3 });
console.log("Worker listening for jobs...");Start it with bun run src/worker.ts.
listen() does five things before it returns.
- Registers every
options.cronschedule, so recurring jobs exist from boot. - Creates each pg-boss queue with its job's declared
queuePolicy. - Drains any dispatch intents left over from a crash.
- Starts consuming, and re-drains intents every five seconds.
- Installs
SIGINTandSIGTERMhandlers.
It resolves to a handle whose stop() removes the signal handlers and shuts the
adapter down.
The two options land differently per adapter.
| Option | pg-boss | BullMQ |
|---|---|---|
teamSize | Ignored. pg-boss 12 has no such option. | Becomes the worker concurrency. |
batchSize | Jobs fetched per poll. | Ignored. |
QUESTPIE runs a fetched pg-boss batch one job at a time. To get more throughput there, run more worker processes.
Shutdown is handled by default
Set gracefulShutdown: false to opt out. shutdownSignals changes which
signals are caught. shutdownTimeoutMs defaults to 10000, after which the
process is force-exited.
One batch, then exit
A serverless function or a cron tick should not hold a listener open. Drain one bounded batch and return.
import { app } from "#questpie";
export async function handler() {
const { processed } = await app.queue.runOnce({ batchSize: 25 });
return { processed };
}runOnce() relays pending dispatch intents, processes one adapter batch, then
relays again. It resolves to { processed }, the count. batchSize defaults to 10.
Pass jobs: [...] to restrict it to some of your jobs. Either the registration
key or the durable name works, and it normalises both.
Pushed batches
Cloudflare Queues pushes to you. A Worker's queue export is the entrypoint,
and questpie/adapters/cloudflare builds it for you.
import { createCloudflareWorkerHandlers } from "questpie/adapters/cloudflare";
import { app } from "#questpie";
export default createCloudflareWorkerHandlers(app);That gives you fetch, queue and scheduled at once. Take
createCloudflareQueueHandler(app) on its own if you already export the other
two. Both call createPushConsumer() and translate Cloudflare's batch into the
shape it wants, so you never cast.
Every delivery drains pending intents first, then hands the batch to the
adapter. Nothing runs when no batch arrives. So add a Cron Trigger calling
await app.queue.drain(), or committed work can sit forever. See
Transactional dispatch.
Check before you call
Each adapter advertises what it supports on app.queue.capabilities. Read the
flag instead of catching the throw when one codebase targets more than one
runtime.
if (app.queue.capabilities.longRunningConsumer) {
await app.queue.listen();
}| Flag | Gates |
|---|---|
longRunningConsumer | listen() |
runOnceConsumer | runOnce() |
pushConsumer | createPushConsumer() |
scheduling | schedule(), unschedule() |
singleton | Advertises singletonKey support. |
executionTerminalState | secretPayload |
scheduling is the odd one. .schedule() throws when it is false, but
options.cron registration skips quietly instead. So on Cloudflare Queues a
cron job is never registered and nothing complains. The scheduled handler
above is how you get it back. It publishes every job whose options.cron
matches the trigger's own cron string, so declare the same expression in
wrangler.toml.
questpie/queue also exports startJobWorker(app.queue, options) and
runJobWorkerOnce(app.queue, options). They wrap listen() and runOnce() and
add nothing. startJobWorker returns void rather than the handle, so call
listen() directly when you want stop().
Delivery is at least once
A crash between broker acceptance and the saved receipt can republish. Retries
carry the same dispatchId, but the broker can still deliver twice. Make
handlers idempotent.
Dispatching
The three methods a job gets on the queue client, every option they take, and the difference between an idempotency key and a singleton key.
Secret payloads
What to do when a job has to carry a short-lived bearer value your broker should never be able to read, and what QUESTPIE will and will not promise about it.