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.
Every job on app.queue.<name> carries the same small surface. Each method
starts the adapter on first use. The two that take a payload parse it against
the job's schema before anything reaches the broker.
| Method | What it does | Resolves to |
|---|---|---|
publish(payload, options?) | Enqueue one run. | string | null |
schedule(payload, cron, options?) | Register a recurring run. | void |
unschedule() | Cancel every schedule for the job. | void |
publish(payload, options?)
const dispatchId = await queue.sendWelcomeEmail.publish(
{ userId: "usr_123" },
{ priority: 10, idempotencyKey: "welcome:usr_123" },
);The declared return type is string | null. No path through the queue client
returns null, so what you get is always the dispatchId, a UUID. Narrow it or
assert it if your lint rules mind.
publish() throws when any of these hold.
- The payload fails
schema. idempotencyKeyis empty, or longer than 512 characters.idempotencyKeyandsingletonKeyare both set.secretPayloadis set without anidempotencyKey.secretPayloadis set on an adapter that cannot prove terminal broker state.
schedule(payload, cron, options?)
cron is the second positional argument, not a field in options.
await queue.generateReport.schedule({ kind: "daily" }, "0 6 * * *");Its options are PublishOptions minus idempotencyKey, startAfter and
secretPayload. A recurring run has no single logical identity. A first delay
means nothing to it. And it has no one-off ledger row to erase a key from. It
throws on an adapter without scheduling, which today means Cloudflare Queues.
For a fixed schedule with no payload, prefer options.cron on the job. See
Recurring work.
unschedule()
Takes no arguments and cancels every scheduled occurrence of the job. It throws on an adapter without scheduling.
await queue.generateReport.unschedule();PublishOptions
The second argument to publish(). It is merged over the job's own options,
so a call-time value wins.
| Option | Type | What it does |
|---|---|---|
idempotencyKey | string | Logical identity, 1 to 512 characters. |
singletonKey | string | Adapter-native dedup while the job is queued or active. |
queuePolicy | string | pg-boss queue policy, applied when the queue is created. |
secretPayload | boolean | Encrypt the payload. See Secret payloads. |
priority | number | Run order. pg-boss runs higher first, BullMQ runs lower first. |
startAfter | number | string | Date | Delay the first run. A number is seconds. |
retryLimit | number | Retries after a failed run. |
retryDelay | number | Seconds between retries. |
retryBackoff | boolean | Use exponential backoff. |
expireInSeconds | number | How long a run may stay active. pg-boss retries or fails it past this, and defaults to 900. |
cron is the one job option with no PublishOptions twin. Use schedule().
Not every adapter acts on every option. Cloudflare Queues acts on startAfter
and retryDelay only, and rejects either past 24 hours. Its own max_retries
bounds attempts there, so retryLimit just logs a warning. BullMQ turns
expireInSeconds into completed-job retention rather than an active-run cap.
The two keys
They look alike and do unrelated things.
idempotencyKey is yours. It is scoped to the durable job name and hashed
into a stable dispatchId. Publish twice with the same key and you get the same
dispatchId back. The first accepted payload and options win. A later call does
not overwrite them. So put a version marker in the key when the work changes
shape and has to dispatch again.
Outside a transaction and without a key, publish() mints a random UUID and
goes straight to the adapter. With a key, it goes through the dispatch ledger so
repeated calls resolve to one logical run. Inside a transaction the routing
changes again. See
Transactional dispatch.
singletonKey is the broker's. It suppresses duplicates while work is in
flight, and means nothing once the job finishes. What counts as in flight is the
queue's policy, below. QUESTPIE rejects the two keys together. A publish
suppressed by a singleton has no new logical identity, so it cannot produce a
receipt for something that was never accepted.
`singletonKey` needs a queue policy on pg-boss
A pg-boss queue's policy is fixed when the queue is created. On a standard
queue the key is stored and never enforced. Declare queuePolicy on the job
so the worker and every publisher agree.
Why the ledger and not the broker
Call publish() twice with the same key and the second call never reaches the
adapter. The ledger row is the gate, not the broker. questpie_queue_dispatch
holds a unique index on the job name and the key. The insert does nothing on
conflict. The second call reads back the stored dispatchId and returns.
A broker only dedupes while it still holds the job. pg-boss deletes a completed
job after seven days by default. Once that row is gone the id is free again.
The ledger row stays, so a key keeps its dispatchId for good.
The broker id is the second line. It catches a publish QUESTPIE could not
confirm and had to retry. That retry carries the same dispatchId. pg-boss and
BullMQ publish under it as their own job id. So each rejects the replay itself.
Cloudflare Queues takes no caller-supplied id. A replay there is a second
physical delivery under one dispatchId.
queuePolicy
These five are what the option type accepts. The behaviour is pg-boss's, and it
extends to each singletonKey when you set one.
| Value | What it allows |
|---|---|
standard | Everything. No dedup. pg-boss's own default. |
short | One queued job. Active jobs are unlimited. |
singleton | One active job. Queued jobs are unlimited. |
stately | One job per state, so one queued and one active. |
exclusive | One job, queued or active. |
Declare it in the job's options, not on a publish call. A worker's listen()
creates every queue with its job's declared policy before it consumes anything,
so both sides agree. Whoever creates the queue first wins. Passing the policy
only at publish time makes that a race. Adapters without policies ignore it.
export default job({
name: "rebuild-sitemap",
schema: z.object({}),
options: { queuePolicy: "stately" },
handler: async () => {},
});
await queue.rebuildSitemap.publish({}, { singletonKey: "sitemap" });Types
Payload types are inferred from schema, so annotations are rare. To name one
elsewhere, use InferJobPayload.
import type { InferJobPayload } from "questpie/queue";
import type sendWelcomeEmail from "../jobs/send-welcome-email";
type WelcomePayload = InferJobPayload<typeof sendWelcomeEmail>;
// ^? { userId: string }It resolves to never for anything that is not a job definition. There is an
InferJobResult<T> beside it. The queue client never surfaces a handler's
return value, so that one has little use.
To replace the handler context type across every job, augment
Questpie.JobHandlerContext. Whatever you put in it replaces AppContext
rather than merging with it. Leave it empty and you keep the default.
declare global {
namespace Questpie {
interface JobHandlerContext {}
}
}questpie/queue exports the whole surface: JobDefinition, JobHandlerArgs,
PublishOptions, QueueClient, QueueJobClient, QueueAdapter,
QueueAdapterCapabilities, InferJobPayload, WorkerOptions and the rest.
Lifecycles
What "singleton" and "request" actually do, when each instance is built and thrown away, and the two ways an async create can fail on you.
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.