- Docs
- Background work
- Outbox
Outbox
Record events in the same transaction as your data and deliver them at-least-once, with an inbox for idempotent consumers.
Use outbox to fix the dual-write hazard: a booking row and its booking.created event are two writes, and the row can commit while the event is lost. Store.Record(ctx, tx, msg) persists the message inside the caller’s db.Tx, so both commit or neither does. A relay then publishes it at-least-once, and Inbox makes consumers idempotent.
Use it for domain events, webhook fan-out, cache invalidation, and audit trails. Delivery is at-least-once, so pair it with Inbox (or your own idempotency key) on the consumer.
Record an event and consume it once
Section titled “Record an event and consume it once”One transaction, one event, one idempotent consumer side effect:
package main
import ( "context" "fmt"
outboxdb "github.com/zenta-dev/zever/adapters/outbox/db" "github.com/zenta-dev/zever/config" "github.com/zenta-dev/zever/container" coredb "github.com/zenta-dev/zever/core/db" "github.com/zenta-dev/zever/core/outbox")
func main() { cfg := config.Default() cfg.DB.Options.Path = ":memory:" c := container.New(cfg)
conn, err := c.DB() if err != nil { panic(err) }
ob, err := outboxdb.OpenFromDB(conn, outboxdb.Options{ Publisher: outbox.PublisherFunc(func(_ context.Context, msg outbox.Message) error { fmt.Println("published", msg.Topic, string(msg.Payload)) return nil }), }) if err != nil { panic(err) } defer ob.Close()
ctx := context.Background() err = coredb.WithTx(ctx, conn, nil, func(ctx context.Context, tx coredb.Tx) error { if _, err := tx.Exec(ctx, `CREATE TABLE bookings (id TEXT PRIMARY KEY)`); err != nil { return err } if _, err := tx.Exec(ctx, `INSERT INTO bookings (id) VALUES (?)`, "b-1"); err != nil { return err } return ob.Record(ctx, tx, outbox.Message{ ID: "b-1", Topic: "booking.created", Payload: []byte(`{"id":"b-1"}`), }) }) fmt.Println("recorded:", err)
inbox, ok := ob.(outbox.Inbox) if !ok { panic("outbox store does not implement outbox.Inbox") } err = coredb.WithTx(ctx, conn, nil, func(ctx context.Context, tx coredb.Tx) error { return inbox.Process(ctx, tx, "b-1", func(ctx context.Context, tx coredb.Tx) error { fmt.Println("side effect ran") return nil }) }) fmt.Println("inbox:", err)}recorded: <nil>side effect raninbox: <nil>The relay is not started here. Start publishes stored messages through the same Publisher.
Record inserts in the caller’s transaction (a nil tx fails with ErrTxRequired) and injects trace context from ctx into the stored headers, so the consumer continues the trace.
Inbox.Process records eventID in the caller’s transaction and runs the side effect only when the ID is new (INSERT ... ON CONFLICT DO NOTHING), so at-least-once delivery applies side effects once.
Understand relay delivery and failure
Section titled “Understand relay delivery and failure”- The relay claims a batch with an atomic
UPDATE ... RETURNING(postgres addsFOR UPDATE SKIP LOCKED, sqlite serializes on a process-local mutex), publishes each message through thePublisher, marks successesprocessed, and retries failures withshared/retrybackoff. - Messages that exhaust
max_attemptsare markedfailed: that is the dead-letter queue, left for inspection and repair throughoutbox.Admin(List/Requeue/Purge, plusDeleteron thedbadapter), driven by thezever outboxCLI. - Processed rows older than
retentionare deleted. Status()reportsPending,Processed,Failed,LastError, andStalled(oldest pending waits too long).- Any number of replicas can relay one table without double-publishing. If a replica crashes mid-publish, the row returns to pending when its
lock_secondslease expires. - When an observability provider is wired (
Options.Provider, whichcontainer.Outbox()injects), the relay emitsoutbox.*metrics andoutbox.publish/outbox.consumespans (see Outbox Ops).
Configure the outbox
Section titled “Configure the outbox”OUTBOX_ADAPTER=dbOUTBOX_DSN=postgres://app:secret@db:5432/appOUTBOX_TABLE=outboxOUTBOX_INBOX_TABLE=inboxOUTBOX_PUBLISHER=eventbusOUTBOX_POLL_INTERVAL=1sOUTBOX_BATCH_SIZE=100OUTBOX_MAX_ATTEMPTS=5OUTBOX_RETENTION=604800000000000OUTBOX_LOCK_SECONDS=30OUTBOX_DEDICATED_POOL=falseOUTBOX_ADAPTER=dbOUTBOX_DSN=postgres://app:secret@db:5432/appOUTBOX_POLL_INTERVAL=1000000000Keys are strict snake_case with integer-nanosecond durations. Zero means the adapter default. Validate rejects negative intervals or budgets, unsafe table names, and a publisher outside {eventbus, queue, ""}. All keys: outbox config reference.
Core never imports eventbus or queue. Your wiring bridges a transport to the Publisher interface, and Options.Publisher only records which one.
Choose an adapter
Section titled “Choose an adapter”| Adapter | Package | Notes |
|---|---|---|
db |
adapters/outbox/db |
Default. Durable sqlite or postgres polling relay, DLQ, retention. Also implements Inbox. |
memory |
adapters/outbox/memory |
Dev and tests. Publishes synchronously and keeps nothing, so a crash between Record and the publisher’s ack loses the event. |
cdc |
adapters/outbox/cdc |
Postgres logical replication via pg_logical_emit_message. No polling loop. Needs wal_level=logical. |
The cdc adapter emits inside the caller’s transaction, so a rollback drops the event. Its consumer tails the WAL through a replication slot and acknowledges the LSN only after a successful publish.
OUTBOX_ADAPTER=cdcOUTBOX_DSN=postgres://app:secret@db:5432/appOUTBOX_PREFIX=zever_outboxOUTBOX_SLOT=zever_outbox_slotOUTBOX_PUBLICATION=zever_outbox_pubThe cdc adapter requires non-empty dsn, slot, publication, and prefix. Live cdc tests are gated behind POSTGRES_DSN.
Resolve the store from the container
Section titled “Resolve the store from the container”import ( "github.com/zenta-dev/zever/config" "github.com/zenta-dev/zever/container")
func openStore() error { c := container.New(config.Default()) ob, err := c.Outbox() if err != nil { return err } defer ob.Close() return nil}Outbox() resolves lazily and caches; on error the cache clears so the next call retries. It also injects the resolved observability.Provider into Options.Provider, so a container-opened store emits the relay metrics and spans described in Outbox Ops without extra wiring. The container-opened store records messages but cannot Start its relay, because core Options carry no Publisher. Wire one through the adapter constructor (OpenFromDB in adapters/outbox/db, New in adapters/outbox/cdc and adapters/outbox/memory), as above.
Production checklist
Section titled “Production checklist”Store.Close() errortakes no context. The container joins close errors viaerrors.Join, and panics become*ClosePanicError.- The
dbadapter shares one pool per exact DSN (first opener’smax_connswins). Usededicated_pool: trueunder sustained load. - Payloads can carry PII: keep them small and reference-backed, and never log raw option maps (use
config.RedactedServices).
Troubleshooting
Section titled “Troubleshooting”Start fails with “publisher is nil”
Cause: the store was opened through the container or registry path, which carries no Publisher.
Fix: wire one through the adapter constructor (OpenFromDB or New) as in the example.
Messages sit pending forever
Cause: the relay is not running (Start never called), or Status().Stalled is true.
Fix: call Start, and check LastError for the claim or publish error.
Failed rows accumulate
Cause: the destination rejects the payload, or the Publisher errors. max_attempts (default 5) bounds retries.
Fix: inspect last_error on the row, fix the cause, and re-drive the row.
Consumers apply duplicate side effects
Cause: the consumer is not idempotent.
Fix: give every message a stable ID and dedupe through Inbox.Process (or your own idempotency key) in the same transaction as the side effect.
The cdc adapter fails to start
Cause: the server lacks wal_level=logical, or the publication and slot names do not match the configured values.
Fix: set wal_level=logical and align the names. Live tests run with POSTGRES_DSN set.