@voila.dev/outbox
Domain events written in the same transaction as the change and delivered at least once, with retries, backoff and parking, plus cron jobs, on Effect.
A change commits, then the process dies before the “order placed” email goes out. Or the email goes out and the transaction rolls back. Publishing an event next to a database write is never reliable; publishing it in the write is.
- Transactional outbox:
publishinserts the event in the same transaction as the change. They commit together or not at all. - At-least-once delivery: a worker claims due messages (
FOR UPDATE SKIP LOCKED, so several instances never take the same row), runs the subscribers, and retries failures with exponential backoff. - Poison messages are parked, not retried forever: after
maxAttemptsfailures, or at once when the payload no longer decodes. - Jobs: cron-scheduled ticks with a timeout, a span and the same metrics.
Subscriber runs and job ticks report through unitMetrics from
@voila.dev/effect-ddd (unit_runs_total and
unit_duration, kind subscriber / job).
bun add @voila.dev/outbox effectEvents and a bus
Events are your own Schema.TaggedClasses. A channel names where they travel
and how they are encoded; the bus service is yours too.
import { EventBus, type EventChannel } from "@voila.dev/outbox";
import { Context, Schema } from "effect";
class OrderPlaced extends Schema.TaggedClass<OrderPlaced>()("OrderPlaced", {
orderId: Schema.String,
total: Schema.Number,
}) {}
const OrderEvent = Schema.Union([OrderPlaced /* , OrderShipped … */]);
type OrderEvent = typeof OrderEvent.Type;
export const orderChannel: EventChannel<OrderEvent> = {
name: "orders",
schema: OrderEvent,
};
export class OrderEvents extends Context.Service<
OrderEvents,
EventBus<OrderEvent>
>()("OrderEvents") {}
export const OrderEventsLayer = EventBus.layer(OrderEvents, orderChannel);Publish inside the transaction that makes the change:
const placeOrder = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
const events = yield* OrderEvents;
yield* sql`INSERT INTO orders ${sql.insert(order)}`;
yield* events.publish(new OrderPlaced({ orderId: order.id, total: 42 }));
}).pipe(sql.withTransaction);A failed publish fails the caller, on purpose: swallowing it would let the
change commit without its events.
Each message gets a time-ordered UUIDv7 from the MessageIds reference, which
needs no layer; provide your own (Effect.provideService(MessageIds, { next }))
to pin ids in a test or use another scheme.
Subscribers and the worker
import { outboxWorker, Subscriber } from "@voila.dev/outbox";
const sendConfirmation = Subscriber.make({
name: "orders.send-confirmation",
tag: "OrderPlaced",
run: (event: OrderPlaced, { id }) =>
mailer.send({ to: event.orderId, idempotencyKey: id }),
});
export const OrderWorkerLayer = Layer.effectDiscard(
Effect.forkScoped(
outboxWorker(orderChannel, [sendConfirmation], {
maxAttempts: 5, // then parked
backoff: exponentialBackoff({ base: "1 second", max: "1 hour" }),
pollInterval: "1 second",
}),
),
);Delivery is at least once: a subscriber may see an event again after a crash
or a sibling’s failure (every subscriber of a message runs before it is
retried), so it stays idempotent — the message id is a ready-made key.
deliver(channel, subscribers, options) is one pass, if you would rather tick
it from a job; it returns { claimed, delivered, retried, parked }.
Stores
OutboxStore is the contract (append, claim, complete, retry, park,
prune).
- In memory, for tests:
inMemoryOutboxStore()returns{ layer, messages };messages()lets a test assert on what was published without a worker. Time comes from theClock, so aTestClockdrives leases and backoff. It has no transaction to join. - Postgres:
PostgresOutboxStore.layer({ table?: "outbox" })from@voila.dev/outbox/postgres, on anySqlClientfromeffect/sql— the package ships no driver, provide the client your application already has (@effect/sql-pg, or your own overpgorBun.SQL).
import { PostgresOutboxStore } from "@voila.dev/outbox/postgres";
const OutboxLayer = Layer.mergeAll(OrderEventsLayer, OrderWorkerLayer).pipe(
Layer.provideMerge(PostgresOutboxStore.layer()),
Layer.provide(PgClientLayer),
);Migration
The table is yours to create, with your migration tool:
migrations/0001_create_outbox.sql
(also importable as @voila.dev/outbox/migrations/0001_create_outbox.sql).
To use another table name, rename it in the file and pass it as table.
Delivered rows stay until you prune them (store.prune({ olderThan: Duration.days(7) }), a good job). Parked rows stay until someone looks:
SELECT id, tag, attempts, last_error FROM outbox WHERE state = 'parked';
-- redeliver once the cause is fixed
UPDATE outbox SET state = 'pending', attempts = 0, available_at = now()
WHERE id = '…';Jobs
import { Job, Jobs, scheduleJob } from "@voila.dev/outbox";
const pruneOutbox = Job.make({
name: "prune-outbox",
every: "0 3 * * *", // cron, UTC
timeout: "5 minutes", // default 10 minutes
run: Effect.flatMap(OutboxStore, (store) =>
store.prune({ olderThan: Duration.days(7) }),
).pipe(Effect.map((pruned) => `pruned=${pruned}`)),
});
export const jobs = Jobs.of([pruneOutbox]); // refuses duplicate names
Layer.effectDiscard(
Effect.forEach(jobs.all, (job) => Effect.forkScoped(scheduleJob(job))),
);run resolves to a one-line summary, which runJob logs. A cron expression
that does not parse throws at import. scheduleJob runs in this process; with
several instances every instance ticks, so keep jobs idempotent or feed
job.cron and runJob(job) to a single runner such as ClusterCron.