# Dispatching (/docs/code/jobs/dispatching)

---
title: Dispatching
description: 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.
kind: reference
package: questpie
---

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?)`

```ts
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`.

```ts
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](/docs/code/jobs#recurring-work).

## `unschedule()`

Takes no arguments and cancels every scheduled occurrence of the job. It throws
on an adapter without scheduling.

```ts
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](/docs/code/jobs/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](/docs/infrastructure/queue/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.

<Callout type="warn" title="`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.
</Callout>

### 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.

```ts
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`.

```ts
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.

```ts
declare global {
	namespace Questpie {
		interface JobHandlerContext {}
	}
}
```

`questpie/queue` exports the whole surface: `JobDefinition`, `JobHandlerArgs`,
`PublishOptions`, `QueueClient`, `QueueJobClient`, `QueueAdapter`,
`QueueAdapterCapabilities`, `InferJobPayload`, `WorkerOptions` and the rest.
