QUESTPIE
CodeJobs

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.

View markdown

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.

MethodWhat it doesResolves 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.
  • idempotencyKey is empty, or longer than 512 characters.
  • idempotencyKey and singletonKey are both set.
  • secretPayload is set without an idempotencyKey.
  • secretPayload is 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.

OptionTypeWhat it does
idempotencyKeystringLogical identity, 1 to 512 characters.
singletonKeystringAdapter-native dedup while the job is queued or active.
queuePolicystringpg-boss queue policy, applied when the queue is created.
secretPayloadbooleanEncrypt the payload. See Secret payloads.
prioritynumberRun order. pg-boss runs higher first, BullMQ runs lower first.
startAfternumber | string | DateDelay the first run. A number is seconds.
retryLimitnumberRetries after a failed run.
retryDelaynumberSeconds between retries.
retryBackoffbooleanUse exponential backoff.
expireInSecondsnumberHow 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.

ValueWhat it allows
standardEverything. No dedup. pg-boss's own default.
shortOne queued job. Active jobs are unlimited.
singletonOne active job. Queued jobs are unlimited.
statelyOne job per state, so one queued and one active.
exclusiveOne 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.

On this page