@makerkit/workers-outbox 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,1121 @@
1
- # Temporary Holding Version
1
+ # workers-outbox
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A transactional outbox for Postgres on Cloudflare Workers (Hyperdrive + Queues + Cron Triggers).
4
+
5
+ Enqueue a job **inside your own database transaction**. It runs if and only if the transaction commits, survives isolate death, retries with backoff, and never runs for the wrong job.
6
+
7
+ > Status: pre-release.
8
+
9
+ This README explains why the library exists and when to use it, then builds up from a four-step quick start to production patterns:
10
+
11
+ 1. [Quick start](#quick-start): one job, enqueued with your business write.
12
+ 2. [Outcomes and retries](#outcomes-and-retries), [typed dependencies](#typed-dependencies), [enqueueing in depth](#enqueueing-in-depth).
13
+ 3. [Completing atomically](#completing-atomically), [fine-grained retry rules](#fine-grained-retry-rules), [scheduled and recurring jobs](#scheduled-and-recurring-jobs), [lanes](#lanes-isolating-slow-jobs).
14
+ 4. [Testing](#testing), [production setup](#production-setup), [performance](#performance), [operating](#operate), [best practices](#best-practices).
15
+
16
+ Examples import the outbox from the package and your app's own code from relative modules (`./jobs`, `./mailer`, `./users`, …); `Env` and `ExecutionContext` are the types `wrangler types` generates. Every `ts` block is typechecked against the library (`pnpm check:readme`).
17
+
18
+ [`examples/worker`](examples/worker) is a complete, typechecked Worker.
19
+
20
+ ## Why
21
+
22
+ Most side effects of a web request (a welcome email, a CRM sync, a webhook, a search-index update) must happen **if and only if** the request's database write commits. Doing both directly is a _dual write_, and it fails in both orders:
23
+
24
+ ```ts
25
+ import { db } from './db';
26
+ import { sendWelcomeEmail } from './mailer';
27
+ import { insertUser } from './users';
28
+
29
+ // ✗ Commit, then send: if the isolate dies or the provider times out between the two lines,
30
+ // the user exists and never gets the email. Nothing will ever retry it.
31
+ const created = await db.transaction((tx) => insertUser(tx, input));
32
+
33
+ await sendWelcomeEmail(created.id, {
34
+ signal: AbortSignal.timeout(30_000),
35
+ idempotencyKey: created.id,
36
+ });
37
+ ```
38
+
39
+ Send first and the opposite happens: the email goes out for a signup that then rolls back. The outbox pattern fixes this by writing the side effect as a row in the **same transaction** as the business data, and running it afterwards from that durable row.
40
+
41
+ On Workers, the usual tools don't do this:
42
+
43
+ - **Cloudflare Queues and Workflows** can't take part in a Postgres transaction. A `queue.send()` after commit is still a dual write.
44
+ - **Postgres job queues** (pg-boss, graphile-worker) assume a long-lived worker process that polls. Workers have no such process, only short invocations.
45
+
46
+ This library keeps the job in your Postgres and uses Cloudflare only to wake it up. A Queue nudge after commit makes jobs start within seconds. A Cron Trigger sweep is the durable backstop: it recovers jobs whose isolate died and runs anything a lost nudge missed. It was extracted from a production outbox, keeping what survived incidents and fixing what didn't.
47
+
48
+ ## When to use it
49
+
50
+ **A good fit:**
51
+
52
+ - You use Cloudflare
53
+ - A Worker that writes to **PostgreSQL 17+ through Hyperdrive with Drizzle**, and needs side effects tied to those writes: transactional email, webhooks, syncs to CRMs, billing or search, provisioning, cache invalidation.
54
+ - Work that is **idempotent, or can be made so**. Delivery is at-least-once; every handler gets a stable `idempotencyKey` to pass to providers that dedupe.
55
+ - Multi-tenant SaaS: jobs carry a `tenantKey` that scopes dedupe, operator actions and erasure.
56
+ - Scheduled and recurring work that can run a few minutes late: newsletters, weekly digests, trial-ending reminders. See [scheduled and recurring jobs](#scheduled-and-recurring-jobs).
57
+ - Teams that want jobs inspectable with SQL, next to the data they belong to, rather than in another system.
58
+
59
+ **Not a good fit:**
60
+
61
+ | If you need… | Because | Consider |
62
+ | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
63
+ | A side effect with no database write to tie it to | There is no transaction to join; the outbox only adds a round trip | Cloudflare Queues directly |
64
+ | D1, Durable Objects or another database | Postgres only, and Drizzle only in v1 | Queues, Durable Object alarms |
65
+ | Multi-step workflows: durable steps, sleeps, waiting for events | One job is one handler call; there are no checkpoints inside it | Cloudflare Workflows, DBOS, or a chain of jobs (see below) |
66
+ | Jobs longer than 10 minutes | `defineJob` rejects `timeoutMs` over 10 min, so every attempt fits one 15-min invocation | Split the work into smaller jobs, or use Workflows |
67
+ | Strict ordering per key | Jobs run in due-time order, not FIFO per entity | Make handlers order-independent (versions, last-write-wins) |
68
+ | Priority, per-tenant fairness, per-kind concurrency/rate limits | Deferred; not in v1 | Lanes for isolation; a rate limiter in your handler |
69
+ | Exactly-once side effects with a provider that can't dedupe | A crash after the call but before settling re-runs the handler | A provider with idempotency keys, or a dedupe table |
70
+ | Large payloads | Payloads live in the hot job table; 256 KiB cap by default | Store the data in R2, enqueue its key |
71
+ | Thousands of jobs per second, sustained | Throughput grows with Postgres connections, and each job is several writes to one table in your primary database | A dedicated queue or streaming system |
72
+
73
+ ## Install
74
+
75
+ ```sh
76
+ pnpm add @makerkit/workers-outbox drizzle-orm pg
77
+ ```
78
+
79
+ Requires **PostgreSQL 17 or newer**. Peer dependencies: `drizzle-orm`; `pg` 8.15+ (and `@types/pg`) for the `./pg` connectors; `@electric-sql/pglite` for `./testing`. Payload schemas can be any Standard Schema validator (zod, valibot, arktype).
80
+
81
+ ## Quick start
82
+
83
+ Four steps: tables, a job, the outbox, and the Worker's handlers.
84
+
85
+ **1. Add the tables.** Re-export them from your Drizzle schema, then generate and apply the migration with drizzle-kit as usual:
86
+
87
+ ```ts
88
+ // drizzle/schema.ts
89
+ export { outboxSchema, outboxJob, outboxAttempt } from '@makerkit/workers-outbox/drizzle';
90
+ ```
91
+
92
+ **2. Define a job.** A kind, a payload schema, a timeout, and a handler that returns an outcome:
93
+
94
+ ```ts
95
+ // src/jobs.ts
96
+ import { defineJob, ok } from '@makerkit/workers-outbox';
97
+ import { z } from 'zod';
98
+
99
+ import { sendWelcomeEmail } from './mailer';
100
+
101
+ export const welcomeEmail = defineJob({
102
+ kind: 'email.welcome',
103
+ payload: z.object({ userId: z.string() }),
104
+ timeoutMs: 30_000,
105
+ async handle({ userId }, ctx) {
106
+ await sendWelcomeEmail(userId, {
107
+ signal: ctx.signal, // aborts once the attempt has timed out
108
+ idempotencyKey: ctx.idempotencyKey, // stable across retries: delivery is at-least-once
109
+ });
110
+
111
+ return ok();
112
+ },
113
+ });
114
+ ```
115
+
116
+ **3. Create the outbox and enqueue inside your transaction.** The user row and the job commit together, or neither does:
117
+
118
+ ```ts
119
+ // src/outbox.ts
120
+ import { createOutbox } from '@makerkit/workers-outbox';
121
+ import { createPgConnector } from '@makerkit/workers-outbox/pg';
122
+
123
+ import { welcomeEmail } from './jobs';
124
+
125
+ // One outbox per invocation; it connects lazily, never at construction.
126
+ export const outboxFor = (env: Env) =>
127
+ createOutbox({
128
+ jobs: [welcomeEmail],
129
+ connect: createPgConnector(env.OUTBOX_DB.connectionString),
130
+ queue: env.OUTBOX_QUEUE,
131
+ });
132
+ ```
133
+
134
+ ```ts
135
+ // src/signup.ts, inside your fetch handler (`env` is the handler's)
136
+ import { db } from './db';
137
+ import { welcomeEmail } from './jobs';
138
+ import { outboxFor } from './outbox';
139
+ import { insertUser } from './users';
140
+
141
+ const user = await outboxFor(env).transaction(db, async (tx, enqueue) => {
142
+ const user = await insertUser(tx, input);
143
+
144
+ await enqueue(welcomeEmail, { payload: { userId: user.id }, dedupeKey: `welcome:${user.id}` });
145
+
146
+ return user;
147
+ });
148
+ ```
149
+
150
+ `outbox.transaction` nudges the queue once the transaction commits, so the job starts within seconds. A repeated `dedupeKey` returns the existing job instead of creating a second one.
151
+
152
+ **4. Wire the Worker.** The queue consumer runs nudged jobs; the cron trigger recovers crashed ones and catches anything a lost nudge missed:
153
+
154
+ ```ts
155
+ // src/index.ts
156
+ import { createQueueHandler, createScheduledHandler } from '@makerkit/workers-outbox/cloudflare';
157
+
158
+ import { app } from './app';
159
+ import { outboxFor } from './outbox';
160
+
161
+ export default {
162
+ fetch: app.fetch,
163
+ queue: createQueueHandler(outboxFor),
164
+ scheduled: createScheduledHandler(outboxFor),
165
+ };
166
+ ```
167
+
168
+ ```jsonc
169
+ // wrangler.jsonc
170
+ {
171
+ "compatibility_flags": ["nodejs_compat"], // node-postgres
172
+ // Same database as your app, through a Hyperdrive config with caching disabled.
173
+ "hyperdrive": [{ "binding": "OUTBOX_DB", "id": "<outbox hyperdrive id>" }],
174
+ "queues": {
175
+ "producers": [{ "binding": "OUTBOX_QUEUE", "queue": "outbox" }],
176
+ "consumers": [{ "queue": "outbox" }],
177
+ },
178
+ "triggers": { "crons": ["*/5 * * * *"] },
179
+ }
180
+ ```
181
+
182
+ That's a working outbox. Each section below adds one concern; before going live, go through the [setup checklist](examples/worker/README.md#setup-checklist).
183
+
184
+ ## Outcomes and retries
185
+
186
+ A handler returns its result rather than throwing it:
187
+
188
+ | Outcome | Effect |
189
+ | ------------------------- | ------------------------------------------------------------------------------------------------------------------- |
190
+ | `ok()` | Job succeeded. `ok({ commit: tx => … })` runs your DB writes in the same transaction as the `succeeded` transition. |
191
+ | `retry({ code, after? })` | Retry with backoff, or after `after` ms. |
192
+ | `fail({ code })` | Dead-letter, no more retries. |
193
+ | `cancel({ reason })` | Cancel the job. |
194
+
195
+ A thrown error goes through `classify`, which defaults to `retry({ code: 'unexpected' })`. The retry policy is per job:
196
+
197
+ ```ts
198
+ import { defineJob, fail, ok, retry } from '@makerkit/workers-outbox';
199
+ import { z } from 'zod';
200
+
201
+ import { isHttp4xx } from './http';
202
+ import { sendWelcomeEmail } from './mailer';
203
+
204
+ export const welcomeEmail = defineJob({
205
+ kind: 'email.welcome',
206
+ payload: z.object({ userId: z.string() }),
207
+ timeoutMs: 30_000, // the lease is derived from this
208
+ retry: {
209
+ maxAttempts: 20, // default 20
210
+ backoff: { baseMs: 30_000, maxMs: 3_600_000 }, // exponential, jittered
211
+ },
212
+ // Maps a thrown error to an outcome.
213
+ classify: (err) => (isHttp4xx(err) ? fail({ code: 'http_4xx' }) : retry({ code: 'unexpected' })),
214
+ async handle({ userId }, ctx) {
215
+ // ctx: jobId, kind, attempt, idempotencyKey, signal, deadline, remainingMs(), db, deps,
216
+ // tenantKey, correlationId, metadata
217
+ await sendWelcomeEmail(userId, { signal: ctx.signal, idempotencyKey: ctx.idempotencyKey });
218
+
219
+ return ok();
220
+ },
221
+ });
222
+ ```
223
+
224
+ To special-case one error and keep the default for the rest, return `undefined` from `classify` (or call the exported `defaultClassify` yourself):
225
+
226
+ ```ts
227
+ import { defineJob, ok, retry } from '@makerkit/workers-outbox';
228
+ import { z } from 'zod';
229
+
230
+ export const nightlyPurge = defineJob({
231
+ kind: 'retention.nightly',
232
+ payload: z.object({ day: z.string() }),
233
+ timeoutMs: 60_000,
234
+ // 55P03 (lock_not_available): give way and come back; anything else gets the default.
235
+ classify: (err) =>
236
+ (err as { code?: string }).code === '55P03' ? retry({ code: 'yielded' }) : undefined,
237
+ async handle() {
238
+ return ok();
239
+ },
240
+ });
241
+ ```
242
+
243
+ `retry({ code, after })` overrides the backoff, e.g. with a provider's `Retry-After`. A job dead-letters once its attempts run out; `warnAtStreak` emits an event when one error code keeps repeating. Per-code budgets are in [fine-grained retry rules](#fine-grained-retry-rules).
244
+
245
+ ## Typed dependencies
246
+
247
+ Handlers get their clients through `ctx.deps` rather than module globals, so each invocation builds them from its own `env`. Bind the deps type once with `defineJobFor`; `createOutbox` then refuses to compile without matching `deps`:
248
+
249
+ ```ts
250
+ import { createOutbox, defineJobFor, ok, retry } from '@makerkit/workers-outbox';
251
+ import { createPgConnector } from '@makerkit/workers-outbox/pg';
252
+ import { z } from 'zod';
253
+
254
+ import { createMailer, type Mailer } from './mailer';
255
+
256
+ interface Deps {
257
+ mailer: Mailer;
258
+ }
259
+
260
+ const defineJob = defineJobFor<Deps>();
261
+
262
+ export const welcomeEmail = defineJob({
263
+ kind: 'email.welcome',
264
+ payload: z.object({ userId: z.string() }),
265
+ timeoutMs: 30_000,
266
+ async handle({ userId }, ctx) {
267
+ const res = await ctx.deps.mailer.send(userId, {
268
+ signal: ctx.signal,
269
+ idempotencyKey: ctx.idempotencyKey,
270
+ });
271
+
272
+ if (res.rateLimited) return retry({ code: 'rate_limited', after: res.retryAfterMs });
273
+
274
+ return ok();
275
+ },
276
+ });
277
+
278
+ export const outboxFor = (env: Env) =>
279
+ createOutbox({
280
+ jobs: [welcomeEmail],
281
+ deps: { mailer: createMailer(env) }, // a compile error if missing or the wrong shape
282
+ connect: createPgConnector(env.OUTBOX_DB.connectionString),
283
+ queue: env.OUTBOX_QUEUE,
284
+ });
285
+ ```
286
+
287
+ With several jobs, `deps` must satisfy all of them at once.
288
+
289
+ ## Enqueueing in depth
290
+
291
+ `enqueue` takes optional fields beside the payload, and returns a result rather than throwing:
292
+
293
+ ```ts
294
+ import { db } from './db';
295
+ import { welcomeEmail } from './jobs';
296
+ import { outboxFor } from './outbox';
297
+ import { insertUser } from './users';
298
+
299
+ // `env` and `requestId` come from your fetch handler.
300
+ const outbox = outboxFor(env);
301
+
302
+ const user = await outbox.transaction(db, async (tx, enqueue) => {
303
+ const user = await insertUser(tx, input);
304
+
305
+ const job = await enqueue(welcomeEmail, {
306
+ payload: { userId: user.id },
307
+ dedupeKey: `welcome:${user.id}`, // unique per tenant
308
+ tenantKey: user.orgId, // scopes dedupe and operator actions
309
+ correlationId: requestId, // find related jobs later
310
+ delayMs: 60_000, // run no earlier than a minute from now
311
+ metadata: { source: 'signup' }, // passed to the handler as ctx.metadata
312
+ });
313
+
314
+ // A rejected job is a result, not a throw: throw it, or the user commits without its job.
315
+ if (job.status !== 'created' && job.status !== 'exists') throw new Error(job.status);
316
+
317
+ return user;
318
+ });
319
+ ```
320
+
321
+ Or let the outbox throw: with `createOutbox({ …, strictEnqueue: true })`, `enqueue`, `transaction`'s `enqueue` and a `run` schedule's `enqueue` throw `EnqueueRefusedError` (its `result` is the refusal) instead, so the business write rolls back with it. The result is then typed as created or exists, and the `StrictOutbox` it returns is accepted wherever an `Outbox` is (`runUntilIdle`, `createQueueHandler`, `createScheduledHandler`):
322
+
323
+ ```ts
324
+ import { createOutbox, EnqueueRefusedError } from '@makerkit/workers-outbox';
325
+ import { createPgConnector } from '@makerkit/workers-outbox/pg';
326
+
327
+ import { db } from './db';
328
+ import { welcomeEmail } from './jobs';
329
+ import { createMailer } from './mailer';
330
+ import { insertUser } from './users';
331
+
332
+ const strict = createOutbox({
333
+ jobs: [welcomeEmail],
334
+ deps: { mailer: createMailer(env) },
335
+ connect: createPgConnector(env.OUTBOX_DB.connectionString),
336
+ strictEnqueue: true,
337
+ });
338
+
339
+ try {
340
+ await strict.transaction(db, async (tx, enqueue) => {
341
+ const user = await insertUser(tx, input);
342
+ const job = await enqueue(welcomeEmail, {
343
+ payload: { userId: user.id },
344
+ dedupeKey: `welcome:${user.id}`,
345
+ });
346
+
347
+ return job.id; // `created` or `exists`: a refusal threw and rolled back the user
348
+ });
349
+ } catch (error) {
350
+ if (error instanceof EnqueueRefusedError) console.error(error.result.status);
351
+ throw error;
352
+ }
353
+ ```
354
+
355
+ In a `run` schedule, a strict refusal fails the whole occurrence (rolled back, `schedule_failed`) where a lenient one counts it as `rejected` and commits the rest.
356
+
357
+ `enqueue` returns one of:
358
+
359
+ - `{ status: 'created' | 'exists', id, kind }`. A repeated `dedupeKey` returns the existing job.
360
+ - `{ status: 'dedupe_conflict', reason: 'kind', existingKind }`. The key exists for a different job kind.
361
+ - `{ status: 'invalid_payload', issues }`. The payload failed its schema, or is over `maxPayloadBytes` (default 256 KiB) as JSON: store large data elsewhere (e.g. R2) and enqueue its key.
362
+
363
+ Pass `outbox.transaction` the top-level `db`, not a transaction: inside a `tx`, drizzle opens a savepoint, and the nudge would go out before the outer commit.
364
+
365
+ **Already inside a transaction you don't own?** Call `outbox.enqueue(tx, …)` directly and nudge after commit yourself. If the nudge is forgotten or lost, the job still runs, minutes later, on the cron sweep:
366
+
367
+ ```ts
368
+ // `ctx` is the fetch handler's ExecutionContext; `user` is already committed by your code.
369
+ const result = await db.transaction((tx) =>
370
+ outbox.enqueue(tx, welcomeEmail, {
371
+ payload: { userId: user.id },
372
+ dedupeKey: `welcome:${user.id}`,
373
+ }),
374
+ );
375
+
376
+ if (result.status === 'created') {
377
+ ctx.waitUntil(outbox.nudge([{ id: result.id, kind: result.kind }])); // never throws
378
+ }
379
+ ```
380
+
381
+ ## Completing atomically
382
+
383
+ A handler's external call and its bookkeeping often belong together: once the email is sent, mark the user as welcomed. Return the write from `ok({ commit })`. It runs in the same transaction that marks the job `succeeded`, so the two commit together or not at all:
384
+
385
+ ```ts
386
+ // src/jobs.ts
387
+ import { defineJobFor, ok, retry } from '@makerkit/workers-outbox';
388
+ import { z } from 'zod';
389
+
390
+ import type { Mailer } from './mailer';
391
+
392
+ import { markWelcomed } from './users';
393
+
394
+ const defineJob = defineJobFor<{ mailer: Mailer }>();
395
+
396
+ export const welcomeEmail = defineJob({
397
+ kind: 'email.welcome',
398
+ payload: z.object({ userId: z.string() }),
399
+ timeoutMs: 30_000,
400
+ async handle({ userId }, ctx) {
401
+ const res = await ctx.deps.mailer.send(userId, {
402
+ signal: ctx.signal,
403
+ idempotencyKey: ctx.idempotencyKey,
404
+ });
405
+
406
+ if (res.rateLimited) return retry({ code: 'rate_limited', after: res.retryAfterMs });
407
+
408
+ // Same transaction as the `succeeded` transition. If it throws, both roll back and the
409
+ // error is classified like a handler throw, so the send may repeat: keep it idempotent.
410
+ return ok({ commit: (tx) => markWelcomed(tx, userId) });
411
+ },
412
+ });
413
+ ```
414
+
415
+ For reactions to _any_ transition, such as flagging a user when their job dead-letters, use the outbox-wide `onTransition` hook. It also runs inside the settle transaction:
416
+
417
+ ```ts
418
+ // src/outbox.ts
419
+ import { createOutbox } from '@makerkit/workers-outbox';
420
+ import { createPgConnector } from '@makerkit/workers-outbox/pg';
421
+
422
+ import { welcomeEmail } from './jobs';
423
+ import { createMailer } from './mailer';
424
+ import { flagUser } from './users';
425
+
426
+ export const outboxFor = (env: Env) =>
427
+ createOutbox({
428
+ jobs: [welcomeEmail],
429
+ deps: { mailer: createMailer(env) },
430
+ connect: createPgConnector(env.OUTBOX_DB.connectionString),
431
+ queue: env.OUTBOX_QUEUE,
432
+ onTransition: async (tx, { job, transition }) => {
433
+ if (transition.to === 'dead') {
434
+ await flagUser(tx, job.id);
435
+ }
436
+ },
437
+ });
438
+ ```
439
+
440
+ A hook that throws rolls the transition back and retries the job with code `hook_failed`, which spends its retry budget, so a hook that always throws ends the job dead rather than looping. Keep hooks to quick database writes: no external calls inside the transaction.
441
+
442
+ **Chaining jobs.** For multi-step work, have each step's handler do one thing, and enqueue the next step from the request that starts the chain, or from your own code once a step's result is committed. Each step gets its own retries, timeout and dedupe key.
443
+
444
+ ## Long work: stopping at the deadline
445
+
446
+ An attempt that is still running at `timeoutMs` is settled as `timeout`, and whatever it returns later is discarded: `ctx.signal` aborts only then, to stop abandoned I/O. To work in slices, stop _before_ the limit and hand the rest to a continuation. `ctx.deadline` is the instant (epoch ms) the attempt times out and `ctx.remainingMs()` the time left, both on the outbox's clock. A test clock moves them only when the test advances it; the timeout itself is real time, so a handler looping on `remainingMs()` under a frozen clock runs until the real `timeoutMs` and times out. Check it before each unit of work, keeping a margin for the slowest unit and the return:
447
+
448
+ ```ts
449
+ // src/jobs.ts
450
+ import {
451
+ createOutbox,
452
+ defineJobFor,
453
+ ok,
454
+ type HandlerOutcome,
455
+ type Outbox,
456
+ } from '@makerkit/workers-outbox';
457
+ import { createPgConnector } from '@makerkit/workers-outbox/pg';
458
+ import { z } from 'zod';
459
+
460
+ import { deleteExpiredBatch } from './retention';
461
+
462
+ // The outbox comes through deps, as a getter: it is created after its jobs.
463
+ const defineJob = defineJobFor<{ getOutbox: () => Outbox }>();
464
+
465
+ const BATCH = 1_000;
466
+ const MARGIN_MS = 10_000; // the slowest batch, plus room to return
467
+
468
+ export const purgeExpired = defineJob({
469
+ kind: 'retention.purge',
470
+ payload: z.object({ day: z.string(), step: z.number().int() }),
471
+ timeoutMs: 60_000,
472
+ async handle({ day, step }, ctx): Promise<HandlerOutcome> {
473
+ let deleted = BATCH;
474
+
475
+ // ctx.db is fenced: once the attempt has timed out, a straggling batch can't commit.
476
+ while (deleted === BATCH && ctx.remainingMs() > MARGIN_MS) {
477
+ deleted = await deleteExpiredBatch(ctx.db, BATCH);
478
+ }
479
+
480
+ if (deleted < BATCH) return ok(); // done
481
+
482
+ // Out of time with rows left: the next step commits together with this one's success.
483
+ return ok({
484
+ commit: async (tx) => {
485
+ const next = await ctx.deps.getOutbox().enqueue(tx, purgeExpired, {
486
+ payload: { day, step: step + 1 },
487
+ dedupeKey: `purge:${day}:${step + 1}`,
488
+ });
489
+
490
+ if (next.status !== 'created' && next.status !== 'exists') throw new Error(next.status);
491
+ },
492
+ });
493
+ },
494
+ });
495
+
496
+ export const outboxFor = (env: Env) => {
497
+ const outbox: Outbox = createOutbox({
498
+ jobs: [purgeExpired],
499
+ deps: { getOutbox: () => outbox },
500
+ connect: createPgConnector(env.OUTBOX_DB.connectionString),
501
+ queue: env.OUTBOX_QUEUE,
502
+ });
503
+
504
+ return outbox;
505
+ };
506
+ ```
507
+
508
+ A job enqueued in `commit` isn't nudged, so it runs on the next cron sweep. To start it at once, enqueue it with `ctx.deps.getOutbox().transaction(ctx.db, …)` before returning `ok()` instead: the step's dedupe key makes a retried step find the same continuation.
509
+
510
+ `ctx.db` is the lane's own session: the outbox's `connect`, role and Hyperdrive config, not your app's database. It is typed as the minimal `OutboxDb`; the object is your connector's `db` (a drizzle database with the `./pg` connectors). Once the attempt returns or times out, every call on it, on a `tx` from its `transaction()` or on a captured method rejects; a query builder created before then and awaited after it is not covered. Never await an external call inside `ctx.db.transaction()`.
511
+
512
+ ## Fine-grained retry rules
513
+
514
+ `retry.perCode` tunes the policy per error code. A number caps how many times in a row one code may occur; an object can also widen the attempt budget for that code, cap its delay, or cancel instead of dead-lettering when it runs out:
515
+
516
+ ```ts
517
+ import { cancel, defineJobFor, fail, ok, retry } from '@makerkit/workers-outbox';
518
+ import { z } from 'zod';
519
+
520
+ import type { Mailer } from './mailer';
521
+
522
+ import { isHttp4xx } from './http';
523
+
524
+ const defineJob = defineJobFor<{ mailer: Mailer }>();
525
+
526
+ export const welcomeEmail = defineJob({
527
+ kind: 'email.welcome',
528
+ payload: z.object({ userId: z.string(), plan: z.enum(['free', 'pro']) }),
529
+ timeoutMs: 30_000,
530
+ retry: {
531
+ maxAttempts: 10,
532
+ perCode: {
533
+ // Throttling isn't failure: allow up to 50 attempts, at most 10 minutes apart.
534
+ rate_limited: { maxAttempts: 50, maxDelayMs: 600_000 },
535
+ // Three timeouts in a row means the provider is down for this job: dead-letter it.
536
+ timeout: 3,
537
+ // A user who keeps bouncing gets 2 tries, then the job is cancelled, not dead-lettered.
538
+ bounced: { streak: 2, onExhaustion: 'cancel' },
539
+ },
540
+ maxCrashes: 3, // consecutive isolate deaths mid-attempt before `dead(crash_loop)`
541
+ },
542
+ classify: (err) => (isHttp4xx(err) ? fail({ code: 'http_4xx' }) : retry({ code: 'unexpected' })),
543
+ async handle({ userId, plan }, ctx) {
544
+ if (plan === 'free' && ctx.attempt > 5) {
545
+ return cancel({ reason: 'not_worth_retrying' });
546
+ }
547
+
548
+ const res = await ctx.deps.mailer.send(userId, {
549
+ signal: ctx.signal,
550
+ idempotencyKey: ctx.idempotencyKey,
551
+ });
552
+
553
+ if (res.rateLimited) return retry({ code: 'rate_limited', after: res.retryAfterMs });
554
+
555
+ return ok();
556
+ },
557
+ });
558
+ ```
559
+
560
+ Budgets count attempts since the last [replay](#operate), and crashes are charged only to `maxCrashes`, never to `maxAttempts`. The defaults cap `timeout` at 5 and `invalid_payload` at 3 in a row; your `perCode` merges over them.
561
+
562
+ ## Scheduled and recurring jobs
563
+
564
+ **Run once, later.** `enqueue(job, { delayMs })` schedules a job for any time in the future: a reminder two weeks before a trial ends, a follow-up a day after signup. `outbox.transaction` sends its nudge with the same delay, so a job due within 24 hours starts within seconds of its due time. A longer delay is past what Queues can hold, so that job runs on the first cron sweep after it's due: up to 5 minutes late with the default `*/5` trigger. So does a job you enqueue with `outbox.enqueue` and nudge yourself, since `outbox.nudge` sends immediately and an early nudge is dropped.
565
+
566
+ **Run on a schedule.** List your recurring jobs in `createScheduledHandler({ schedules })` and add their cron expressions to the Worker. When a tick's cron matches a schedule, the handler enqueues that occurrence's job, then runs the usual sweep:
567
+
568
+ ```ts
569
+ import { createOutbox, defineJob, ok } from '@makerkit/workers-outbox';
570
+ import {
571
+ createQueueHandler,
572
+ createScheduledHandler,
573
+ defineSchedule,
574
+ } from '@makerkit/workers-outbox/cloudflare';
575
+ import { createPgConnector } from '@makerkit/workers-outbox/pg';
576
+ import { z } from 'zod';
577
+
578
+ import { app } from './app';
579
+ import { isoWeek } from './dates';
580
+ import { sendNewsletter } from './newsletter';
581
+
582
+ export const weeklyNewsletter = defineJob({
583
+ kind: 'newsletter.weekly',
584
+ payload: z.object({ week: z.string() }), // e.g. '2026-W45'
585
+ timeoutMs: 120_000,
586
+ async handle({ week }, ctx) {
587
+ await sendNewsletter(week, { signal: ctx.signal, idempotencyKey: ctx.idempotencyKey });
588
+
589
+ return ok();
590
+ },
591
+ });
592
+
593
+ export const outboxFor = (env: Env) =>
594
+ createOutbox({
595
+ jobs: [weeklyNewsletter],
596
+ connect: createPgConnector(env.OUTBOX_DB.connectionString),
597
+ queue: env.OUTBOX_QUEUE,
598
+ });
599
+
600
+ export default {
601
+ fetch: app.fetch,
602
+ queue: createQueueHandler(outboxFor),
603
+ scheduled: createScheduledHandler(outboxFor, {
604
+ schedules: [
605
+ defineSchedule({
606
+ cron: '0 9 * * MON', // must match an entry in triggers.crons exactly
607
+ job: weeklyNewsletter,
608
+ payload: ({ scheduledAt }) => ({ week: isoWeek(scheduledAt) }), // type-checked
609
+ }),
610
+ ],
611
+ }),
612
+ };
613
+ ```
614
+
615
+ ```jsonc
616
+ // wrangler.jsonc: cron expressions run in UTC
617
+ { "triggers": { "crons": ["*/5 * * * *", "0 9 * * MON"] } }
618
+ ```
619
+
620
+ Each occurrence's job is keyed by the tick's scheduled time (`schedule:<name>:<scheduledAt>`, where `name` defaults to the job's kind), so a tick delivered twice enqueues nothing new; pass `dedupeKey` to choose your own. The job is nudged when it's enqueued, so it starts within seconds of the trigger, and from then on it's an ordinary job, with retries, a dead-letter path and operator actions.
621
+
622
+ **Fan out** with `run` instead of `job` and `payload`: it gets a transaction and a typed `enqueue`, and everything it enqueues commits together. Derive each dedupe key from `scheduledAt` so a repeated tick finds the jobs it already made:
623
+
624
+ ```ts
625
+ import { defineJob, ok } from '@makerkit/workers-outbox';
626
+ import { defineSchedule } from '@makerkit/workers-outbox/cloudflare';
627
+ import { z } from 'zod';
628
+
629
+ import { syncInsights } from './insights';
630
+ import { listShops } from './shops';
631
+
632
+ export const insightsSync = defineJob({
633
+ kind: 'insights.sync',
634
+ payload: z.object({ shopId: z.string(), day: z.string() }),
635
+ timeoutMs: 60_000,
636
+ async handle({ shopId, day }, ctx) {
637
+ await syncInsights(shopId, day, { signal: ctx.signal });
638
+
639
+ return ok();
640
+ },
641
+ });
642
+
643
+ export const nightlyInsights = defineSchedule({
644
+ cron: '0 2 * * *',
645
+ name: 'insights', // required for run schedules
646
+ async run({ scheduledAt, tx, enqueue }) {
647
+ const day = scheduledAt.toISOString().slice(0, 10);
648
+
649
+ for (const shop of await listShops(tx)) {
650
+ await enqueue(insightsSync, {
651
+ payload: { shopId: shop.id, day },
652
+ dedupeKey: `insights:${shop.id}:${day}`,
653
+ tenantKey: shop.id,
654
+ });
655
+ }
656
+ },
657
+ });
658
+ ```
659
+
660
+ `run` executes on the outbox's connection, so reading app tables (like `listShops` above) needs grants for the outbox role. For a large mailing list, the same pattern enqueues one job per batch of subscribers, so each batch retries on its own and a failure doesn't resend to everyone.
661
+
662
+ What to expect:
663
+
664
+ - **Minute-level precision**, not second-level: a trigger can fire a little late, and a retry runs after its backoff. Fine for newsletters, digests and nightly syncs; the wrong tool for anything that must happen at an exact second.
665
+ - **Transient database errors are retried** within the tick. Anything else, like a throw from `run` or a rejected single-job enqueue, rolls the occurrence back and emits `schedule_failed` (severity `error`); other schedules on the same tick and the sweep still run.
666
+ - **A missed tick is skipped, not caught up.** If Cloudflare doesn't fire a trigger, or every retry fails, that occurrence doesn't run. Alert on `schedule_failed`, and re-run by hand with `enqueue` if it matters.
667
+ - **Every tick runs the sweep**, including schedule ticks. That's cheap, and it means one `scheduled` export serves both.
668
+
669
+ ## Lanes: isolating slow jobs
670
+
671
+ By default every job shares one queue and one consumer, so a burst of slow jobs delays fast ones. A **lane** gives a group of kinds its own queue and consumer. Here, five-minute exports run on `bulk` and can't hold up welcome emails:
672
+
673
+ ```ts
674
+ import { createOutbox, defineJob, ok } from '@makerkit/workers-outbox';
675
+ import {
676
+ createDlqHandler,
677
+ createQueueHandler,
678
+ createScheduledHandler,
679
+ type QueueBatch,
680
+ } from '@makerkit/workers-outbox/cloudflare';
681
+ import { createPgConnector } from '@makerkit/workers-outbox/pg';
682
+ import { z } from 'zod';
683
+
684
+ import { app } from './app';
685
+ import { buildExport } from './exports';
686
+ import { welcomeEmail } from './jobs';
687
+ import { createMailer } from './mailer';
688
+
689
+ export const orgExport = defineJob({
690
+ kind: 'org.export',
691
+ lane: 'bulk', // jobs without a lane run on 'default'
692
+ payload: z.object({ orgId: z.string() }),
693
+ timeoutMs: 300_000, // at most 10 minutes
694
+ retry: { maxAttempts: 5 },
695
+ async handle({ orgId }, ctx) {
696
+ await buildExport(orgId, { signal: ctx.signal });
697
+
698
+ return ok();
699
+ },
700
+ });
701
+
702
+ export const outboxFor = (env: Env) =>
703
+ createOutbox({
704
+ jobs: [welcomeEmail, orgExport],
705
+ deps: { mailer: createMailer(env) },
706
+ connect: createPgConnector(env.OUTBOX_DB.connectionString),
707
+ queue: { default: env.OUTBOX_QUEUE, bulk: env.OUTBOX_BULK_QUEUE }, // one queue per lane
708
+ events: (e) => console.log(JSON.stringify(e)),
709
+ });
710
+
711
+ const mail = createQueueHandler(outboxFor); // claims only 'default' kinds
712
+ const bulk = createQueueHandler(outboxFor, { lane: 'bulk' }); // claims only 'bulk' kinds
713
+ const dlq = createDlqHandler({ events: (e) => console.log(JSON.stringify(e)) });
714
+
715
+ export default {
716
+ fetch: app.fetch,
717
+ queue: (batch: QueueBatch, env: Env, ctx: ExecutionContext) => {
718
+ if (batch.queue === 'outbox-dlq' || batch.queue === 'outbox-bulk-dlq') return dlq(batch);
719
+
720
+ return batch.queue === 'outbox-bulk' ? bulk(batch, env, ctx) : mail(batch, env, ctx);
721
+ },
722
+ scheduled: createScheduledHandler(outboxFor, {
723
+ // Swept before the catch-all, so a backlog of other kinds can't starve signups.
724
+ reserved: [
725
+ { name: 'mail', kinds: ['email.welcome'], budget: { maxJobs: 50, deadlineMs: 45_000 } },
726
+ ],
727
+ retention: { succeededRetentionMs: 3 * 86_400_000 }, // default 7 days; dead/cancelled 90
728
+ }),
729
+ };
730
+ ```
731
+
732
+ ```jsonc
733
+ // wrangler.jsonc (queues)
734
+ {
735
+ "queues": {
736
+ "producers": [
737
+ { "binding": "OUTBOX_QUEUE", "queue": "outbox" },
738
+ { "binding": "OUTBOX_BULK_QUEUE", "queue": "outbox-bulk" },
739
+ ],
740
+ "consumers": [
741
+ { "queue": "outbox", "dead_letter_queue": "outbox-dlq", "max_concurrency": 2 },
742
+ { "queue": "outbox-bulk", "dead_letter_queue": "outbox-bulk-dlq", "max_concurrency": 1 },
743
+ { "queue": "outbox-dlq" },
744
+ { "queue": "outbox-bulk-dlq" },
745
+ ],
746
+ },
747
+ }
748
+ ```
749
+
750
+ Two names are easy to confuse: `defineJob({ lane })` is a named routing lane, while `createOutbox({ lanes })` is how many Postgres connections one invocation may hold (default 1). The cron sweep still covers every lane, so a lost nudge is caught either way. A job whose lease is longer than the sweep's 180 s budget, like the export above, isn't run by cron: the sweep hands it to its lane's queue instead.
751
+
752
+ Every consumer invocation holds connections, so peak outbox connections are about `lanes × (sum of max_concurrency) + lanes` for cron. Here that's 1 × (2 + 1) + 1 = 4. Keep it under your Hyperdrive origin limit and Postgres `max_connections`, after your app's own traffic.
753
+
754
+ ## Testing
755
+
756
+ `./testing` runs the real outbox against an in-memory database:
757
+
758
+ ```ts
759
+ import { createOutbox } from '@makerkit/workers-outbox';
760
+ import { createOutboxTestDatabase, runUntilIdle } from '@makerkit/workers-outbox/testing';
761
+ import { expect, it } from 'vitest';
762
+
763
+ import { welcomeEmail } from '../src/jobs';
764
+ import { fakeMailer } from './mailers'; // records each userId it "sends" to
765
+
766
+ it('sends the welcome email', async () => {
767
+ const test = await createOutboxTestDatabase(); // pglite with the schema applied
768
+ const sent: string[] = [];
769
+ const outbox = createOutbox({
770
+ jobs: [welcomeEmail],
771
+ deps: { mailer: fakeMailer(sent) },
772
+ connect: test.connect,
773
+ });
774
+
775
+ await outbox.transaction(test.db, (tx, enqueue) =>
776
+ enqueue(welcomeEmail, { payload: { userId: 'u1' }, dedupeKey: 'welcome:u1' }),
777
+ );
778
+
779
+ expect(await runUntilIdle(outbox)).toMatchObject({ succeeded: 1 });
780
+ expect(sent).toEqual(['u1']);
781
+ });
782
+ ```
783
+
784
+ Without a clock, `runUntilIdle` runs only what is due now. To test retries and delays, give the outbox a test clock; `runUntilIdle` then skips ahead to each next due job:
785
+
786
+ ```ts
787
+ import { createOutbox } from '@makerkit/workers-outbox';
788
+ import {
789
+ createOutboxTestDatabase,
790
+ createTestClock,
791
+ runUntilIdle,
792
+ } from '@makerkit/workers-outbox/testing';
793
+ import { expect, it } from 'vitest';
794
+
795
+ import { welcomeEmail } from '../src/jobs';
796
+ import { flakyMailer } from './mailers'; // throws on the first `failures` sends
797
+
798
+ it('retries until the provider recovers', async () => {
799
+ const test = await createOutboxTestDatabase();
800
+ const clock = createTestClock();
801
+ const outbox = createOutbox({
802
+ jobs: [welcomeEmail],
803
+ deps: { mailer: flakyMailer({ failures: 2 }) },
804
+ connect: test.connect,
805
+ clock,
806
+ });
807
+
808
+ await outbox.transaction(test.db, (tx, enqueue) =>
809
+ enqueue(welcomeEmail, { payload: { userId: 'u1' }, dedupeKey: 'welcome:u1' }),
810
+ );
811
+
812
+ const report = await runUntilIdle(outbox, { clock, db: test.db });
813
+
814
+ expect(report).toMatchObject({ retrying: 2, succeeded: 1 });
815
+ });
816
+ ```
817
+
818
+ `runUntilIdle` throws after `maxRounds` (default 100), so a job that retries forever fails the test instead of hanging it. pglite is a single session, so it can't reproduce multi-connection races; this repo's real-Postgres tests run with `pnpm db:up && pnpm test:pg`.
819
+
820
+ ## Production setup
821
+
822
+ The quick start runs; these make it resilient. The [setup checklist](examples/worker/README.md#setup-checklist) lists them all, since missing pieces degrade quietly rather than fail.
823
+
824
+ **A dead-letter queue.** Messages that keep failing land there; the job row stays durable and the next sweep re-drives it. One `queue()` export serves both consumers:
825
+
826
+ ```ts
827
+ import {
828
+ createDlqHandler,
829
+ createQueueHandler,
830
+ createScheduledHandler,
831
+ type QueueBatch,
832
+ } from '@makerkit/workers-outbox/cloudflare';
833
+
834
+ import { app } from './app';
835
+ import { outboxFor } from './outbox';
836
+
837
+ const queue = createQueueHandler(outboxFor); // nudge → targeted claim; drain → bounded sweep
838
+ const dlq = createDlqHandler({ events: (e) => console.log(JSON.stringify(e)) });
839
+
840
+ export default {
841
+ fetch: app.fetch,
842
+ queue: (batch: QueueBatch, env: Env, ctx: ExecutionContext) =>
843
+ batch.queue === 'outbox-dlq' ? dlq(batch) : queue(batch, env, ctx),
844
+ scheduled: createScheduledHandler(outboxFor), // recover → drain → cleanup
845
+ };
846
+ ```
847
+
848
+ ```jsonc
849
+ // wrangler.jsonc
850
+ {
851
+ "compatibility_flags": ["nodejs_compat"],
852
+ "hyperdrive": [{ "binding": "OUTBOX_DB", "id": "<caching-disabled config>" }],
853
+ "queues": {
854
+ "producers": [{ "binding": "OUTBOX_QUEUE", "queue": "outbox" }],
855
+ "consumers": [
856
+ { "queue": "outbox", "dead_letter_queue": "outbox-dlq", "max_concurrency": 2 },
857
+ { "queue": "outbox-dlq" },
858
+ ],
859
+ },
860
+ "triggers": { "crons": ["*/5 * * * *"] },
861
+ "version_metadata": { "binding": "CF_VERSION_METADATA" }, // pass as `version`
862
+ }
863
+ ```
864
+
865
+ **A dedicated Hyperdrive config** with caching disabled, for the outbox only.
866
+
867
+ **Roles and session guardrails.** The outbox runs as whatever role `OUTBOX_DB` connects as. **Required:** set `idle_in_transaction_session_timeout` on that role, so a transaction orphaned by a dead isolate can't hold its locks indefinitely. **Recommended:** make it a dedicated `outbox_worker` role, so its timeouts don't apply to your app's queries and it can only touch what it needs. Whichever role it is also runs your handlers, `onTransition` hooks and `ok({ commit })` writes, so it needs grants on the app tables they touch. Put settings on the role, not in a `SET`: under Hyperdrive's transaction pooling a session `SET` leaks to the next client. Run as the schema owner, after the migrations:
868
+
869
+ ```sql
870
+ -- No password here, so none lands in logs or history, and the role can't log in until you set one:
871
+ -- `\password outbox_worker` in psql, or your provider's console.
872
+ CREATE ROLE outbox_worker LOGIN;
873
+
874
+ -- Replace app_role with the role your app connects as.
875
+ GRANT USAGE ON SCHEMA outbox TO outbox_worker, app_role;
876
+
877
+ -- The worker runs every outbox statement.
878
+ GRANT SELECT, INSERT, UPDATE, DELETE ON outbox.job, outbox.attempt TO outbox_worker;
879
+
880
+ -- The producer only enqueues (insert, then a re-read on a dedupe hit).
881
+ GRANT SELECT, INSERT ON outbox.job TO app_role;
882
+
883
+ -- Whatever your handlers, onTransition hooks and ok({ commit }) touch, for example:
884
+ -- GRANT SELECT, UPDATE ON public.users TO outbox_worker;
885
+
886
+ -- Required: ends a session left inside an open transaction, releasing its locks.
887
+ ALTER ROLE outbox_worker SET idle_in_transaction_session_timeout = '60s';
888
+
889
+ -- Recommended: a statement waiting on a lock fails instead of queueing behind it.
890
+ ALTER ROLE outbox_worker SET lock_timeout = '5s';
891
+
892
+ -- Optional backstop: no transaction on this role outlives a 15-minute invocation.
893
+ ALTER ROLE outbox_worker SET transaction_timeout = '15min';
894
+ ```
895
+
896
+ Role settings apply to new sessions only; Hyperdrive's existing origin connections keep the old values until they're recycled. A later migration that adds a table needs the grants re-run, or set `ALTER DEFAULT PRIVILEGES FOR ROLE <owner> IN SCHEMA outbox GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO outbox_worker`.
897
+
898
+ **A schema check at deploy.** `OUTBOX_MIGRATIONS` refuses a server older than PostgreSQL 17, but with drizzle-kit nothing checks the version. Run `verifyOutboxSchema` in CI or a release step and fail on any result:
899
+
900
+ ```ts
901
+ import { verifyOutboxSchema } from '@makerkit/workers-outbox/drizzle';
902
+
903
+ import { db } from './db';
904
+
905
+ // [] when they match; otherwise missing or changed columns, constraints and indexes,
906
+ // invalid indexes (a failed CREATE INDEX CONCURRENTLY), or an unsupported server version.
907
+ const drift = await verifyOutboxSchema(db);
908
+
909
+ if (drift.length > 0) {
910
+ throw new Error(`outbox schema: ${JSON.stringify(drift)}`);
911
+ }
912
+ ```
913
+
914
+ Not using drizzle-kit? Apply `OUTBOX_MIGRATIONS` from `./sql`, one statement at a time, in order.
915
+
916
+ **Time limits.** Each invocation has one deadline shared by everything it awaits: claims, handlers, settlements, recovery, cleanup, queue sends and connection acquisition. The queue and scheduled handlers take `invocationBudgetMs` (default 840 s, under Cloudflare's 15-minute limit); the scheduled handler's `recoveryBudgetMs` (default 120 s) caps recovery's share, so a crash backlog can't starve fresh work, and is held back from `schedules`, so slow schedules can't starve recovery. A job whose claim arrives too late to run safely never starts: its attempt closes as `claim_late`, charging neither an attempt nor a crash, and it retries in 5 s.
917
+
918
+ ## Performance
919
+
920
+ Each lane runs one job at a time on one Postgres connection: claim, handler, settle, next. A job costs about five database round trips besides the handler (one claim statement, and a settle transaction of four). Throughput is therefore set by connections and handler time:
921
+
922
+ ```
923
+ jobs/min ≈ 60 × outbox connections ÷ (handler time + 5 × DB round trip)
924
+ ```
925
+
926
+ Outbox connections are `lanes × (sum of the consumers' max_concurrency) + lanes` for cron: 3 with the defaults (`lanes: 1`, `max_concurrency: 2`).
927
+
928
+ Measured with `pnpm test:load` on a laptop, against PostgreSQL 17 in local Docker limited to 2 CPUs, one run each. Local round trips are far cheaper than Hyperdrive's, so treat these as upper bounds per connection, not Cloudflare figures:
929
+
930
+ | Handler | Connections | jobs/s | Per connection |
931
+ | ------- | ----------- | ------- | -------------- |
932
+ | 2 ms | 1 | 113–161 | 113–161 |
933
+ | 2 ms | 8 | 919 | 115 |
934
+ | 200 ms | 32 | 135 | 4.2 (~250/min) |
935
+
936
+ - **Scaling is near-linear in connections** up to 8 in these runs, with latency flat. Past that, the 2-CPU database was the limit, so higher counts are unmeasured.
937
+ - **Slow handlers dominate.** With a 200 ms handler, each connection does about 250 jobs/min whatever the database, so three default connections handle roughly 750 jobs/min, or about a million a day.
938
+ - **To go faster,** raise the queue consumers' `max_concurrency` or `lanes`, and split slow kinds onto their own [lane](#lanes-isolating-slow-jobs). Each step spends connections, so stay under your Hyperdrive origin limit and Postgres `max_connections`, after your app's own traffic.
939
+ - **Whose limits these are.** The connection budget, round-trip cost and per-job write churn come with any transactional outbox on Postgres over Hyperdrive. Running one job at a time per connection is this library's choice: it gives each lane an exclusive session, so settle transactions can never interleave. That choice is what ties throughput to connections when handlers are slow.
940
+ - **Sustained volume** also depends on autovacuum and [retention](#lanes-isolating-slow-jobs) keeping the `outbox` tables small: every job is an insert, a few updates and, after retention, a delete.
941
+
942
+ To size it for your workload, run the load test against a staging Postgres server with your handlers' typical latency. It creates and drops its own throwaway database, so the role needs `CREATEDB`; never point it at production:
943
+
944
+ ```sh
945
+ OUTBOX_LOAD=1 OUTBOX_LOAD_HANDLER_MS=200 OUTBOX_PG_URLS=postgres://… pnpm vitest run src/load --testTimeout=600000
946
+ ```
947
+
948
+ In production, `aggregateAttemptOutcomes` from `./inspect` reports real attempt durations.
949
+
950
+ ## Operate
951
+
952
+ ```ts
953
+ import { outboxFor } from './outbox';
954
+
955
+ // `env` from your handler; `jobId`, `jobIds` and `user` from the request.
956
+ const outbox = outboxFor(env);
957
+
958
+ await outbox.cancel({ id: jobId }, 'user_deleted');
959
+ await outbox.replay(jobId); // dead/cancelled → fresh retry budget
960
+ await outbox.retryNow(jobId); // make a pending/retrying job due now, and nudge it
961
+ await outbox.cancelMany(jobIds, 'reason'); // also: replayMany
962
+ await outbox.replay(jobId, { tenantKey: user.orgId }); // only if it belongs to this tenant
963
+ await outbox.eraseTenant(user.orgId); // deletes every job of one tenant
964
+ ```
965
+
966
+ The optional `{ tenantKey }` scope reports out-of-scope jobs as `not_found`. Mind the difference: an omitted `tenantKey` means _any_ tenant, `null` means only jobs enqueued without one. Pass the tenant explicitly whenever an action is on a tenant's behalf.
967
+
968
+ Read-only inspection lives in `./inspect`: `getJob`, `listJobs`, `countJobs`, `findJobsByCorrelationId`, `countBacklog`, `aggregateAttemptOutcomes`, plus `evaluateBacklog` to grade a backlog count against thresholds. Each query runs in a read-only transaction bounded by `statementTimeoutMs` (default 30 s), so a dashboard can't hold a connection indefinitely:
969
+
970
+ ```ts
971
+ import { getJob, listJobs } from '@makerkit/workers-outbox/inspect';
972
+
973
+ import { db } from './db';
974
+
975
+ const job = await getJob(db, { id: jobId, tenantKey: user.orgId }); // null when not found
976
+ const dead = await listJobs(db, { status: 'dead', limit: 50, statementTimeoutMs: 5_000 });
977
+ ```
978
+
979
+ `listJobs` defaults to oldest first. Set `order: 'desc'` for newest first; both creation time and job ID use that direction. Pass `nextCursor` back as `cursor` with the same filters and order. Drop the cursor when either changes.
980
+
981
+ Omit `status` in `listJobs` or `countJobs` to include all statuses. Tenant scoping is independent: continue passing `tenantKey` for tenant dashboards. `createdAtOrAfter` includes its boundary; `createdAfter`, `createdBefore`, `finishedAfter` and `finishedBefore` remain exclusive.
982
+
983
+ ```ts
984
+ const latest = await listJobs(db, {
985
+ tenantKey: user.orgId,
986
+ createdAtOrAfter: new Date('2026-11-01T00:00:00.000Z'),
987
+ createdBefore: new Date('2026-12-01T00:00:00.000Z'),
988
+ order: 'desc',
989
+ limit: 50,
990
+ });
991
+ ```
992
+
993
+ `countBacklog` includes `pending` and `retrying` counts for each kind. These count all jobs in those statuses, including future jobs. `due`, `aged`, `scheduled`, `processing`, `stuck` and backlog severity retain their existing meanings.
994
+
995
+ ## Best practices
996
+
997
+ **Enqueueing**
998
+
999
+ - **Derive `dedupeKey` from business identity**, such as `welcome:${user.id}` or `invoice.paid:${invoice.id}`, never from a random value. A retried request then finds its job instead of creating a second one. Keys are permanent per tenant, so to send something again on purpose, put the reason in the key (`reminder:${user.id}:${week}`).
1000
+ - **Check the `enqueue` result and throw on a rejection,** or use `strictEnqueue: true`. `invalid_payload` and `dedupe_conflict` are returned, not thrown by default; ignore them and the business row commits without its job.
1001
+ - **Pass the top-level `db` to `outbox.transaction`**, not a `tx`, or the nudge goes out before the real commit.
1002
+ - **Validate at the edge with the job's own schema** (`welcomeEmail.payload`) so a bad request fails before it opens a transaction, as in [the example Worker](examples/worker/src/index.ts).
1003
+ - **Enqueue references, not snapshots.** Put ids in the payload and read current state in the handler; store anything large in R2 and enqueue its key.
1004
+
1005
+ **Handlers**
1006
+
1007
+ - **Make every handler idempotent.** Delivery is at-least-once: pass `ctx.idempotencyKey` to providers that dedupe, or record what you've done and check it first.
1008
+ - **Pass `ctx.signal` to every I/O call.** It fires once the attempt has been settled as `timeout`; a handler that ignores it keeps running after its attempt is already retried. A result after it is discarded: to stop early and still return `ok()`, watch `ctx.remainingMs()` ([long work](#long-work-stopping-at-the-deadline)).
1009
+ - **Return outcomes for errors you understand.** `fail` for permanent errors (4xx, validation), `retry({ after })` for `Retry-After`, `cancel` when the work is moot. Leave `classify`'s default `retry` for the unexpected.
1010
+ - **Set `timeoutMs` close to the real worst case.** The lease is derived from it, so an overgenerous timeout delays crash recovery, and a long lease can't run on the cron sweep.
1011
+ - **Never await an external call inside a database transaction**, whether yours or the one in `ok({ commit })` and `onTransition`. Do the network call first, then commit the bookkeeping.
1012
+ - **Build clients per invocation through `deps`**, never at module scope, so each invocation uses its own `env` and connections.
1013
+
1014
+ **Deploys**
1015
+
1016
+ - **Evolve payload schemas additively.** Jobs already in the table carry the old shape and are validated again when they run. Add optional fields; don't rename or remove required ones.
1017
+ - **Keep a handler registered until its jobs have drained.** Claims are filtered to registered kinds, so jobs of a kind you removed sit pending forever.
1018
+ - **Pass `version: env.CF_VERSION_METADATA`** so during a gradual rollout an older deploy leaves jobs from a newer one alone.
1019
+ - **Run `verifyOutboxSchema` in CI** and fail the release on drift.
1020
+
1021
+ **Operations**
1022
+
1023
+ - **Send `events` somewhere you alert on.** `settle_failed`, `infra_error`, `nudge_skipped`, `schedule_failed` and dead-letters are how a broken setup shows up; nothing else will fail loudly.
1024
+ - **Budget connections before raising `lanes` or `max_concurrency`.** Peak is about `lanes × (sum of max_concurrency) + lanes`; past the origin limit, both the outbox and your app suffer.
1025
+ - **Scope operator actions to a tenant.** Pass `{ tenantKey }` whenever an action is on a tenant's behalf, and call `eraseTenant` when a tenant leaves.
1026
+ - **Use `correlationId`** (a request or trace id) so you can find every job a request produced.
1027
+
1028
+ ## Configuration
1029
+
1030
+ Everything beyond `jobs` and `connect` is optional:
1031
+
1032
+ ```ts
1033
+ import { createOutbox } from '@makerkit/workers-outbox';
1034
+ import { createPgConnector } from '@makerkit/workers-outbox/pg';
1035
+
1036
+ import { welcomeEmail } from './jobs';
1037
+ import { createMailer } from './mailer';
1038
+ import { flagUser } from './users';
1039
+
1040
+ export const outboxFor = (env: Env) =>
1041
+ createOutbox({
1042
+ jobs: [welcomeEmail],
1043
+ deps: { mailer: createMailer(env) },
1044
+ connect: createPgConnector(env.OUTBOX_DB.connectionString),
1045
+ queue: env.OUTBOX_QUEUE,
1046
+ version: env.CF_VERSION_METADATA, // gradual rollouts: older deploys skip newer jobs
1047
+ events: (e) => console.log(JSON.stringify(e)), // structured logs and metrics
1048
+ onTransition: async (tx, { job, transition }) => {
1049
+ // Same transaction as the job row write: both commit, or neither does.
1050
+ if (transition.to === 'dead') {
1051
+ await flagUser(tx, job.id);
1052
+ }
1053
+ },
1054
+ });
1055
+ ```
1056
+
1057
+ | Option | Purpose |
1058
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1059
+ | `jobs`, `connect` | Required. Registered jobs and a connection factory. |
1060
+ | `deps` | Passed to handlers as `ctx.deps`. Required, and type-checked, once any job declares deps. |
1061
+ | `queue` | One Queue, or one per lane. Without it, jobs run on the cron sweep only. |
1062
+ | `lanes` | Connections per invocation. Default 1. |
1063
+ | `version` | e.g. `env.CF_VERSION_METADATA`. Gates newer jobs from older deploys during a rollout. |
1064
+ | `events` | Structured event sink. Misconfigurations surface here (`nudge_skipped`, `settle_failed`, `infra_error`). |
1065
+ | `onTransition` | Hook run inside the settle transaction. A throw rolls back and retries the job. |
1066
+ | `redactError` | Scrub error messages before they are persisted. |
1067
+ | `warnAtStreak` | Emit an event when the same error code repeats this many times. |
1068
+ | `recordNudges` | Record nudge results on the row (on the lane's idle connection when it holds one). Cron handoff nudges are always recorded: their cooldown reads `nudge_sent_at`. |
1069
+ | `operationTimeoutMs` | Bound for one operator action (`cancel`, `replay`, `retryNow`, …): acquisition, SQL and close. Past it the connection is destroyed and the call rejects. Default 60 s. |
1070
+ | `maxPayloadBytes` | Largest payload accepted, as JSON bytes. Default 256 KiB; larger ones return `invalid_payload`. |
1071
+ | `strictEnqueue` | `true`: enqueues throw `EnqueueRefusedError` instead of returning `invalid_payload` / `dedupe_conflict`, and `createOutbox` returns a `StrictOutbox`. Default `false`. |
1072
+ | `generateId` | Job id generator. Default `uuidv7()`. Must return a UUID; prefer time-ordered ids for index locality. |
1073
+ | `clock` | For tests: see [Testing](#testing). |
1074
+
1075
+ ### Connections
1076
+
1077
+ `./pg` ships two node-postgres connectors:
1078
+
1079
+ - `createPgConnector(connectionString, { connectionTimeoutMs?, onError? })`: a fresh `pg.Client` per lane, the shape Hyperdrive expects. Connection timeout defaults to 10 s.
1080
+ - `createPgPoolConnector(pool, { onError? })`: checks clients out of a pool you own. Set the pool's `connectionTimeoutMillis`, and attach `pool.on('error', …)`: node-pg re-emits a returned client's late errors on the pool, and an unlistened one crashes the process.
1081
+
1082
+ Both catch errors a session raises outside a query (such as the FATAL `transaction_timeout` sends) instead of crashing the Worker, report the session as broken so the outbox replaces it, and end a lane idempotently. The pool connector destroys a broken client rather than returning it to the pool.
1083
+
1084
+ Writing your own? `connect` returns a `LaneConnection` the outbox owns until it calls exactly one of:
1085
+
1086
+ - `close()`: the session is healthy; return it (e.g. `client.release()`).
1087
+ - `destroy()`: the session must never be reused (a runaway handler, a settlement that hit its deadline); discard it (e.g. `client.release(true)` or `client.end()`). It is called _before_ a replacement is acquired, so a one-client pool can reconnect. It must be idempotent, and make a later `close()` a no-op.
1088
+
1089
+ Optionally implement `isBroken()`, true once the session is known dead (e.g. its socket errored while idle): the outbox then replaces the lane before its next claim or settlement instead of failing a round trip first. Listen for the client's `'error'` event before connecting, and bound acquisition. [`src/pg/index.ts`](src/pg/index.ts) is a short reference.
1090
+
1091
+ ## Entry points
1092
+
1093
+ | Entry point | Contents |
1094
+ | -------------- | ------------------------------------------------------------------------------------------------------- |
1095
+ | `.` | `defineJob`, `defineJobFor`, `createOutbox`, `ok` / `retry` / `fail` / `cancel` |
1096
+ | `./cloudflare` | `createQueueHandler`, `createScheduledHandler` (with `schedules`), `createDlqHandler`, `defineSchedule` |
1097
+ | `./pg` | `createPgConnector` (Hyperdrive), `createPgPoolConnector` |
1098
+ | `./drizzle` | Drizzle tables, `verifyOutboxSchema` |
1099
+ | `./sql` | `OUTBOX_MIGRATIONS` for non-drizzle-kit setups |
1100
+ | `./inspect` | Read-only operator queries, backlog evaluation |
1101
+ | `./testing` | `createOutboxTestDatabase` (pglite), `createTestClock`, `runUntilIdle` |
1102
+
1103
+ ## Development
1104
+
1105
+ ```sh
1106
+ pnpm install
1107
+ pnpm test # vitest + pglite
1108
+ pnpm typecheck
1109
+ pnpm check:readme # typechecks every ts block in this README
1110
+ pnpm db:up # PostgreSQL 17 in Docker, for the next three
1111
+ pnpm test:pg # the suite against real Postgres
1112
+ pnpm test:load # concurrency and recovery under load (OUTBOX_LOAD_JOBS, _WORKERS, _LANES, _CRASHED)
1113
+ pnpm test:example # examples/worker under wrangler dev (needs wrangler; WRANGLER=/path)
1114
+ pnpm oxlint
1115
+ pnpm format
1116
+ pnpm build # rolldown → dist/
1117
+ ```
1118
+
1119
+ ## License
1120
+
1121
+ MIT