@krizaka/outbox
The transactional outbox: written with the work, delivered at least once by leased workers, retried, then dead-lettered.
Install
npm install @krizaka/outboxAlso published as subpath exports: @krizaka/outbox/settings, @krizaka/outbox/testkit.
The transactional outbox of every Krizaka product. An event is written in the caller's transaction, so it exists exactly when the work committed, and is delivered after the commit, at least once, by workers that lease a batch. No broker: PostgreSQL and FOR UPDATE SKIP LOCKED share the work between instances. It replaces Akinaza's mail_outbox (whose flushOutbox nobody called) and the analytics outboxes the products carried; it is the TypeScript twin of krizaka-messaging's krizaka_outbox (the envelope is id, type, occurredAt, payload).
The guarantee is at least once. Consumers must be idempotent on the message id. A claim is exclusive while its lease is valid. A worker that delivers and then crashes before
completelets the lease expire, and the message is delivered again. The outbox never promises « exactly once ».
import { emitMigration, transaction } from "@krizaka/db";
import { consumeOnce, createOutbox, outboxSchema, runWorker } from "@krizaka/outbox";
import { readOutboxSettings } from "@krizaka/outbox/settings";
emitMigration(outboxSchema, 1, { tables: {} }); // commit the SQL once, in your migrations: outbox.message and outbox.processed
const outbox = createOutbox({ clock: { now: () => new Date() }, settings: () => readOutboxSettings((key) => settings.get(key)) });
// 1. in the use case: the message commits or rolls back with the work
await transaction(connect, async (tx) => {
const redemption = await coupons.redeem(tx, { code, accountId });
await outbox.append(tx, { topic: "credits", type: "evt.coupon.redeemed.v1", payload: { redemptionId: redemption.id }, id: redemption.eventId });
});
// 2. a worker the product starts (Next.js instrumentation.ts); the package has no timer, you pass `sleep`
const stop = new AbortController();
void runWorker({
outbox, query: pool.query.bind(pool) /* a pool-level Query */, owner: `${hostname}:${process.pid}`, topic: "credits", signal: stop.signal,
sleep: (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)),
deliver: async (message) => { /* throw to retry */ },
});
// 3. a consumer with an effect in the database applies it once per message id, in the effect's transaction
await transaction(connect, (tx) => consumeOnce(tx, "credits", message.id, clock.now(), () => credits.grant(tx, …)));append(tx, { topic, type, payload, id?, availableAt? })takes the transaction'sQueryfirst. A givenidmakes the append idempotent (the same fact twice is one message).typeis a routing key,evt.<module>.<fact>.v1like Java's.claim(query, { owner, topic? })isUPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED)and sets the lease.completeandfail(error)act only for the lease's owner (a worker that lost its lease cannot overwrite the new holder).failbacks off —availableAt = now + backoffSeconds × 2^(attempts−1), capped — and aftermaxAttemptsthe message is dead (kept, with its last error;retryDeadgives it another life). A message whose last allowed attempt's lease expired with no answer is dead too, not delivered a ninth time.runWorkerloopsdrainOnce(claim a batch, deliver in order, record each outcome), sleeps through thesleepyou pass when a round found nothing, and ends when the signal is aborted. RundrainOncefrom a scheduler instead if you prefer cron to a loop.- Settings (
@krizaka/outbox/settings, needs@krizaka/settingsandzod):outbox.batchSize(50, 1–500),outbox.leaseSeconds(60, 10–3600: longer than your slowest delivery),outbox.maxAttempts(8, 1–50),outbox.backoffSeconds(30, 1–3600),outbox.backoffMaxSeconds(3600),outbox.retentionDays(7).purgeremoves delivered and dead messages older than the retention: call it from the product's job. - Tables (
outboxSchema, Postgres schemaoutbox):message(id, topic, event_type, payload, created_at, available_at, attempts, leased_until, lease_owner, delivered_at, dead_at, last_error) andprocessed(consumer, message_id), whichconsumeOnceuses: the marker and the effect commit in the same transaction, so a crash leaves neither and a redelivery finds the marker. outboxStoreContract(from@krizaka/outbox/testkit) is the contract every store runs;memoryOutboxStore()is for unit tests.
Exports
The 30 names the package exports, read from its types.
| Name | Kind | What it is |
|---|---|---|
backoffSeconds | function | The seconds a message waits after its attempts-th delivery failed: the base for the first, doubling with each, capped. |
checkMessage | function | |
checkSettings | function | |
ClaimOptions | type | What claim takes. |
Clock | type | |
consumeOnce | function | The idempotent consumer: the outbox delivers AT LEAST ONCE, so a consumer that has an effect in the database applies it once per message id. |
createOutbox | function | The outbox of a product. |
dbOutboxStore | function | The PostgreSQL store of the outbox, in outbox.message (see outboxSchema), over the Query it is given: the caller's transaction for append (so the mes… |
drainOnce | function | One round: leases a batch, delivers each message in order, records each outcome. |
DrainOptions | type | |
DrainSummary | type | |
FAIL_STATE | constant | What became of a failed delivery. |
FailOutcome | type | The result of fail: the message will be retried, is dead, or was not this worker's any more. |
memoryOutboxStore | function | An in-memory outbox store, for unit tests and demos (the same behaviour as the database's: the contract kit runs on both). |
NewMessage | type | What a product appends. |
Outbox | type | The outbox as a product uses it. |
OUTBOX_BOUNDS | constant | The bounds of each setting: [min, max]. |
OUTBOX_CODE | constant | |
OUTBOX_DEFAULTS | constant | The defaults of the settings, and their bounds. |
OutboxCode | type | |
OutboxDeps | type | |
OutboxError | class | |
OutboxMessage | type | The envelope a worker receives: the Java kit's id, type, occurredAt, payload, and where it is in its attempts. |
outboxSchema | constant | The tables of « outbox », in their own Postgres schema (ADR-001 §3). |
OutboxSettings | type | The knobs of the outbox, read from the admin's settings (@krizaka/outbox/settings). |
OutboxStats | type | |
OutboxStore | type | Where the messages live. |
Query | type | |
runWorker | function | The loop a product starts (Next.js instrumentation.ts, a worker process): drain, and sleep when there was nothing. |
WorkerOptions | type |
@krizaka/outbox/settings
| Name | Kind | What it is |
|---|---|---|
outboxSettings | constant | The settings of « outbox », a fragment the product adds to its registry (@krizaka/outbox/settings): the admin tunes the worker without a redeploy. |
readOutboxSettings | function | The outbox's settings as createOutbox takes them, from the product's accessor ((key) => service.get(key, ctx)). |
@krizaka/outbox/testkit
| Name | Kind | What it is |
|---|---|---|
outboxStoreContract | function | What every OutboxStore adapter must do: the same behaviour behind the same port. |
Package
| Version on npm | 0.1.3 (npm) |
| Licence | Apache-2.0 |
| Node.js | >=22 |
| Dependencies | @krizaka/db workspace:^, @krizaka/settings workspace:^, @krizaka/time workspace:^ |
| Peer dependencies | vitest >=4, zod >=3.23 |
| Source | outbox |
Changelog
0.1.3
Patch changes
- The READMEs, comments and published declarations no longer name the products that left the organisation (krizaka/.github#413, rule
no-departed-published). The sources were cleaned in #262 with an empty changeset, so the published tarballs still carried the old text; this release replaces them. No behaviour change. - Updated dependencies [2a7898c]
- Updated dependencies [2272b57]
- @krizaka/[email protected]
- @krizaka/[email protected]
0.1.2
Patch changes
- Updated dependencies [0d19b64]
- @krizaka/[email protected]
- @krizaka/[email protected]
0.1.1
Patch changes
- Updated dependencies [8210a9a]
- @krizaka/[email protected]
0.1.0
Minor changes
- New package (#53): the transactional outbox.
createOutbox(append(tx, …)in the caller's transaction,claimwith a lease overFOR UPDATE SKIP LOCKED,complete,failwith a doubling capped back-off then a dead letter,retryDead,purge,stats),drainOnceandrunWorker(no timer of its own: the product passessleep),consumeOnce(the idempotent consumer, in the effect's transaction),memoryOutboxStore,dbOutboxStore,outboxSchema,@krizaka/outbox/settings(outbox.batchSize,leaseSeconds,maxAttempts,backoffSeconds,backoffMaxSeconds,retentionDays) andoutboxStoreContract. At least once: consumers are idempotent on the message id.
Patch changes
- Updated dependencies [da6d980]
- @krizaka/[email protected]