Help us shape the future of Go! Take the 2026 Go Developer Survey

xoutbox

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 4, 2026 License: MIT Imports: 13 Imported by: 0

README

xoutbox

Go Reference CI

A production-grade Transactional Outbox for Go. Write events in the same database transaction as your business data, and xoutbox delivers them to Kafka, NATS JetStream, RabbitMQ or Redis with no lost messages, crash recovery and per-key ordering. You don't need distributed transactions.

┌──────────── your service ────────────┐          ┌──────────────┐
│ BEGIN                                │          │    Kafka     │
│   INSERT INTO orders ...             │  Worker  │    NATS      │
│   INSERT INTO outbox ...  ◄──────────┼──claim──►│  RabbitMQ    │
│ COMMIT                               │  publish │    Redis     │
└──────────────────────────────────────┘          └──────────────┘

Features

Atomic writes store.Insert(ctx, tx, events...) accepts any *sql.Tx / *sql.DB / *sql.Conn
Horizontal scaling Run any number of workers on any number of pods. They claim rows with FOR UPDATE SKIP LOCKED and leases
Crash recovery If a worker dies mid-batch, its lease expires and another worker picks the events up
Per-key ordering Events with the same Key are published in order. WithOrdering() gives strict head-of-line ordering across workers
Retries with backoff Exponential backoff with jitter, a per-event MaxAttempts, and xoutbox.Permanent(err) to skip retries
Dead-letter queue Exhausted events move to dead. Requeue() replays them
Poison-pill protection Attempts are counted on claim, so an event that crashes the worker can't loop forever
Delayed / scheduled events event.WithDelay(10*time.Minute)
Batch publishing Kafka SendMessages, NATS async acks, RabbitMQ pipelined confirms, Redis pipelines
Low latency Adaptive polling (re-polls immediately when a batch is full) plus worker.Notify() after commit
Graceful shutdown The in-flight batch is always finished and committed before Run returns
Observability log/slog logging, Hooks for metrics/tracing, Stats() for backlog gauges
Housekeeping Automatic retention cleanup of published rows in batches, plus a reaper for stuck rows
Panic safe A panicking publisher counts as a failed attempt and doesn't crash the worker
Tested Unit tests plus integration tests against real Postgres, MySQL, Kafka, NATS, RabbitMQ and Redis

Install

go get github.com/Ali127Dev/xoutbox
Package Backend Notes
xoutbox/postgres PostgreSQL 12+ Single-round-trip claim (UPDATE … RETURNING). Works with pgx/stdlib and lib/pq
xoutbox/mysql MySQL 8.0+, MariaDB 10.6+ go-sql-driver/mysql
xoutbox/kafka Apache Kafka IBM/sarama, idempotent producer, acks=all, key-hash partitioning
xoutbox/nats NATS JetStream Nats-Msg-Id = event ID, so it's deduplicated inside the stream's duplicate window
xoutbox/rabbitmq RabbitMQ Publisher confirms, persistent messages, auto-reconnect
xoutbox/redis Redis Streams / Pub/Sub Standalone, Sentinel and Cluster (UniversalClient), MAXLEN ~ trimming

Quick start (Postgres → Kafka)

db, _ := sql.Open("pgx", os.Getenv("DATABASE_URL"))

store := postgres.New(db)                // postgres.WithTable("events_outbox"), postgres.WithOrdering(), ...
if err := store.Migrate(ctx); err != nil { // or copy postgres/schema.sql into your migrations
    log.Fatal(err)
}

pub, err := kafka.New(kafka.Config{Brokers: []string{"localhost:9092"}})
if err != nil {
    log.Fatal(err)
}
defer pub.Close()

worker := xoutbox.NewWorker(store, pub,
    xoutbox.WithBatchSize(200),
    xoutbox.WithConcurrency(16),
    xoutbox.WithCleanup(time.Minute, 72*time.Hour), // delete published rows after 3 days
)
go worker.Run(ctx) // returns nil once ctx is cancelled and the in-flight batch is committed
Writing events in your transaction
func (s *Service) CreateOrder(ctx context.Context, o Order) error {
    tx, err := s.db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    if _, err := tx.ExecContext(ctx, `INSERT INTO orders (id, total) VALUES ($1, $2)`, o.ID, o.Total); err != nil {
        return err
    }

    evt, err := xoutbox.NewJSONEvent("orders", "order.created", o) // topic, type, payload
    if err != nil {
        return err
    }
    evt = evt.WithKey(o.CustomerID).WithHeader("trace-id", traceID(ctx))

    if err := s.outbox.Insert(ctx, tx, evt); err != nil {
        return err
    }
    if err := tx.Commit(); err != nil {
        return err
    }
    s.worker.Notify() // optional: publish right away instead of waiting for the next poll
    return nil
}

Other brokers

// NATS JetStream (exactly-once into the stream thanks to Nats-Msg-Id dedup)
nc, _ := nats.Connect(nats.DefaultURL)
js, _ := jetstream.New(nc)
pub := xnats.New(js, xnats.WithSubjectPrefix("events."))     // topic "orders" → subject "events.orders"

// RabbitMQ (confirms + persistent + auto-reconnect)
pub, _ := rabbitmq.New(rabbitmq.Config{URL: "amqp://guest:guest@localhost:5672/", Exchange: "events"})

// Redis Streams
rdb := goredis.NewUniversalClient(&goredis.UniversalOptions{Addrs: []string{"localhost:6379"}})
pub := xredis.New(rdb, xredis.Config{MaxLen: 1_000_000})     // xredis.PubSub for fire-and-forget

// Anything else
pub := xoutbox.PublisherFunc(func(ctx context.Context, e xoutbox.Event) error {
    return myClient.Send(ctx, e.Topic, e.Payload)
})

How Event fields map to each broker:

Event Kafka NATS RabbitMQ Redis Stream
Topic topic subject (+prefix) routing key (queue name on default exchange) stream key (+prefix)
Key message key (partition) outbox-key header outbox-key header key field
ID outbox-id header Nats-Msg-Id + header MessageId + header id field
Type outbox-type header header Type + header type field
Headers record headers headers headers headers field (JSON)

To route to different brokers, write a PublisherFunc that switches on e.Topic or e.Type.

Delivery guarantees

xoutbox gives you at-least-once delivery. A message can be published twice if the broker accepts it but the worker crashes before marking it published. Make consumers idempotent by using the outbox-id header / message ID as a dedup key. NATS JetStream does this for you.

Ordering. Inside one batch, events that share a Key are published one after another. If one fails, the rest of its group is put back without using up an attempt. For strict ordering across retries and multiple workers, enable postgres.WithOrdering() / mysql.WithOrdering(): an event is only claimed once every older event with the same key has been published or dead-lettered. Events with an empty key are never blocked.

Worker options

Option Default Description
WithBatchSize(n) 100 Events claimed per poll
WithConcurrency(n) 8 Parallel publishes (per key group) when the publisher isn't a BatchPublisher
WithPollInterval(d) 1s Idle poll interval. A full batch re-polls immediately
WithLease(d) max(30s, 3×timeout) How long claimed rows stay locked to this worker
WithPublishTimeout(d) 10s Timeout per Publish / PublishBatch
WithBackoff(b) exp 1s→5m + jitter Retry delay policy: ExponentialBackoff(base, max) or your own func
WithCleanup(every, retention) 1m, keep forever Housekeeping interval and how long published rows are kept
WithWorkerID(id) host-pid-rand Lease owner name
WithLogger(l) slog.Default() Structured logger
WithHooks(h) none OnPublished, OnFailed(e, err, dead), OnStoreError(op, err)

worker.RunOnce(ctx) processes a single batch, which is handy for cron jobs, Lambdas and tests.

Metrics (Prometheus example)

published := promauto.NewCounterVec(prometheus.CounterOpts{Name: "outbox_published_total"}, []string{"topic"})
failed    := promauto.NewCounterVec(prometheus.CounterOpts{Name: "outbox_failed_total"}, []string{"topic", "dead"})
lag       := promauto.NewHistogram(prometheus.HistogramOpts{Name: "outbox_lag_seconds"})

worker := xoutbox.NewWorker(store, pub, xoutbox.WithHooks(xoutbox.Hooks{
    OnPublished: func(e xoutbox.Event) {
        published.WithLabelValues(e.Topic).Inc()
        lag.Observe(time.Since(e.CreatedAt).Seconds())
    },
    OnFailed: func(e xoutbox.Event, err error, dead bool) {
        failed.WithLabelValues(e.Topic, strconv.FormatBool(dead)).Inc()
    },
}))

// Backlog gauges: scrape store.Stats(ctx) → Pending, Processing, Dead, OldestPending

Operations

n, err := store.Requeue(ctx)            // replay every dead event
n, err := store.Requeue(ctx, id1, id2)  // replay specific events
st, err := store.Stats(ctx)             // backlog snapshot
deleted, err := store.Cleanup(ctx, 24*time.Hour)
fmt.Println(store.SchemaSQL())          // DDL for your migration tool
-- inspect the dead-letter queue
SELECT id, topic, event_type, attempts, last_error, created_at FROM outbox WHERE status = 'dead';

Schema

store.Migrate(ctx) creates the table and indexes idempotently. You can also copy postgres/schema.sql or mysql/schema.sql into your migrations (replace {t}/{n} with the table name). Key columns: seq (insertion order), id, topic, partition_key, payload, headers, status, attempts, max_attempts, locked_by, locked_until, available_at, last_error.

All timestamps come from the database clock, so app-server clock skew and timezone settings don't matter.

Custom backends

Implement xoutbox.Store for another database, or xoutbox.Publisher / xoutbox.BatchPublisher for another broker. internal/storetest holds the conformance suite used by the SQL stores.

type Store interface {
    Claim(ctx context.Context, owner string, limit int, lease time.Duration) ([]Event, error)
    MarkPublished(ctx context.Context, ids []string) error
    MarkFailed(ctx context.Context, owner string, f Failure) error
}

Development

make test               # unit tests
make test-integration   # starts Postgres, MySQL, Kafka, NATS, RabbitMQ, Redis via docker compose

License

MIT

Documentation

Index

Constants

View Source
const (
	HeaderID   = "outbox-id"
	HeaderType = "outbox-type"
	HeaderKey  = "outbox-key"
)

Metadata headers attached to every message by the built-in publishers.

Variables

View Source
var ErrNoTopic = Permanent(errors.New("xoutbox: event has no topic"))

ErrNoTopic is returned when neither the event nor the publisher defines a destination.

Functions

func IsPermanent added in v0.1.0

func IsPermanent(err error) bool

IsPermanent reports whether err was wrapped with Permanent.

func Meta added in v0.1.0

func Meta(e Event) map[string]string

Meta returns the metadata headers merged with the event headers.

func NewID added in v0.1.0

func NewID() string

NewID returns a random UUIDv7 (time-ordered, index friendly).

func Permanent added in v0.1.0

func Permanent(err error) error

Permanent marks err as non-retryable: the event goes straight to the dead-letter state.

func ResolveTopic added in v0.1.0

func ResolveTopic(e Event, def string) (string, error)

ResolveTopic returns e.Topic, falling back to def.

Types

type Backoff added in v0.1.0

type Backoff func(attempt int) time.Duration

Backoff returns the delay before the next attempt, given the attempts made so far.

func ExponentialBackoff added in v0.1.0

func ExponentialBackoff(base, max time.Duration) Backoff

ExponentialBackoff doubles the delay per attempt up to max, with equal jitter.

type BatchPublisher added in v0.1.0

type BatchPublisher interface {
	Publisher
	PublishBatch(ctx context.Context, events []Event) []error
}

BatchPublisher is an optional fast path. The returned slice must have one entry per event (nil on success). The worker uses it automatically.

type Cleaner added in v0.1.0

type Cleaner interface {
	Cleanup(ctx context.Context, retention time.Duration) (deleted int64, err error)
}

Cleaner is implemented by stores that support housekeeping. Cleanup dead-letters events whose lease expired after exhausting their attempts and, if retention > 0, deletes published events older than retention.

type Event

type Event struct {
	// ID uniquely identifies the event. NewEvent fills it with a UUIDv7.
	// Brokers that support de-duplication (NATS JetStream) use it as the message ID.
	ID string
	// Topic is the destination: Kafka topic, NATS subject, RabbitMQ routing key or Redis stream.
	Topic string
	// Key is the partition / ordering key (e.g. an aggregate ID). Events sharing a key
	// are published in insertion order. Empty means unordered.
	Key string
	// Type is a free-form event name such as "order.created".
	Type    string
	Payload []byte
	Headers map[string]string

	// AvailableAt delays delivery until the given time. Zero means immediately.
	AvailableAt time.Time
	// MaxAttempts overrides the store default. Zero means the store default.
	MaxAttempts int

	// Set by the store when an event is claimed.
	Attempts  int
	CreatedAt time.Time
}

Event is a message stored in the outbox and relayed to a broker.

func NewEvent added in v0.1.0

func NewEvent(topic, eventType string, payload []byte) Event

NewEvent creates an event with a fresh time-ordered ID.

func NewJSONEvent added in v0.1.0

func NewJSONEvent(topic, eventType string, v any) (Event, error)

NewJSONEvent marshals v as the payload and sets content-type to application/json.

func (Event) WithDelay added in v0.1.0

func (e Event) WithDelay(d time.Duration) Event

WithDelay returns a copy of e that will not be published before d has elapsed.

func (Event) WithHeader added in v0.1.0

func (e Event) WithHeader(k, v string) Event

WithHeader returns a copy of e with the header set.

func (Event) WithKey added in v0.1.0

func (e Event) WithKey(key string) Event

WithKey returns a copy of e with the ordering key set.

func (Event) WithMaxAttempts added in v0.1.0

func (e Event) WithMaxAttempts(n int) Event

WithMaxAttempts returns a copy of e with a custom attempt limit.

type Execer added in v0.1.0

type Execer interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}

Execer is satisfied by *sql.DB, *sql.Tx and *sql.Conn. Pass your business transaction so the event is committed atomically with your data.

type Failure added in v0.1.0

type Failure struct {
	ID       string
	Attempts int           // attempt counter to persist
	Delay    time.Duration // time until the event becomes claimable again
	Err      string
	Dead     bool // move to dead-letter state instead of retrying
}

Failure describes a failed delivery attempt.

type Hooks added in v0.1.0

type Hooks struct {
	OnPublished  func(e Event)
	OnFailed     func(e Event, err error, dead bool)
	OnStoreError func(op string, err error)
}

Hooks are optional callbacks for metrics and tracing. They must not block.

type Option added in v0.1.0

type Option func(*options)

Option configures a Worker.

func WithBackoff added in v0.1.0

func WithBackoff(b Backoff) Option

WithBackoff sets the retry delay policy (default exponential 1s..5m with jitter).

func WithBatchSize added in v0.1.0

func WithBatchSize(n int) Option

WithBatchSize sets how many events are claimed per poll (default 100).

func WithCleanup added in v0.1.0

func WithCleanup(interval, retention time.Duration) Option

WithCleanup sets how often housekeeping runs (default 1m, 0 disables) and how long published events are retained (default 0 = keep forever).

func WithConcurrency added in v0.1.0

func WithConcurrency(n int) Option

WithConcurrency sets how many events/key-groups are published in parallel (default 8).

func WithHooks added in v0.1.0

func WithHooks(h Hooks) Option

WithHooks installs metrics/tracing callbacks.

func WithLease added in v0.1.0

func WithLease(d time.Duration) Option

WithLease sets how long claimed events stay locked to this worker (default 30s). It must comfortably exceed the time needed to publish a batch.

func WithLogger added in v0.1.0

func WithLogger(l *slog.Logger) Option

WithLogger sets the structured logger (default slog.Default()).

func WithPollInterval added in v0.1.0

func WithPollInterval(d time.Duration) Option

WithPollInterval sets the idle polling interval (default 1s). When a batch is full the worker polls again immediately.

func WithPublishTimeout added in v0.1.0

func WithPublishTimeout(d time.Duration) Option

WithPublishTimeout bounds each Publish/PublishBatch call (default 10s).

func WithWorkerID added in v0.1.0

func WithWorkerID(id string) Option

WithWorkerID sets the lease owner name (default host-pid-random).

type Publisher

type Publisher interface {
	Publish(ctx context.Context, e Event) error
}

Publisher delivers a single event to a broker. Publish must return only after the broker has durably accepted the message.

type PublisherFunc added in v0.1.0

type PublisherFunc func(ctx context.Context, e Event) error

PublisherFunc adapts a function to the Publisher interface.

func (PublisherFunc) Publish added in v0.1.0

func (f PublisherFunc) Publish(ctx context.Context, e Event) error

type Stats added in v0.1.0

type Stats struct {
	Pending       int64
	Processing    int64
	Dead          int64
	OldestPending time.Duration
}

Stats is a snapshot of the outbox backlog.

type Status

type Status string

Status is the lifecycle state of an outbox row.

const (
	StatusPending    Status = "pending"
	StatusProcessing Status = "processing"
	StatusPublished  Status = "published"
	StatusDead       Status = "dead"
)

type Store

type Store interface {
	// Claim leases up to limit due events to owner for the lease duration,
	// increments their attempt counter and returns them ordered by insertion.
	// Events whose lease expired (crashed worker) are claimable again.
	Claim(ctx context.Context, owner string, limit int, lease time.Duration) ([]Event, error)
	// MarkPublished marks events as delivered.
	MarkPublished(ctx context.Context, ids []string) error
	// MarkFailed records a failed attempt. It must be a no-op if owner no longer holds the lease.
	MarkFailed(ctx context.Context, owner string, f Failure) error
}

Store persists outbox events. Implementations must be safe for concurrent use by many workers across many processes.

type Worker

type Worker struct {
	// contains filtered or unexported fields
}

Worker relays events from a Store to a Publisher with at-least-once semantics. Run any number of workers across any number of processes; leases prevent double delivery under normal operation and recover events from crashed workers.

func NewWorker

func NewWorker(store Store, pub Publisher, opts ...Option) *Worker

NewWorker creates a worker. If pub implements BatchPublisher, batches are published in one call.

func (*Worker) ID added in v0.1.0

func (w *Worker) ID() string

ID returns the lease owner name of this worker.

func (*Worker) Notify added in v0.1.0

func (w *Worker) Notify()

Notify wakes the worker immediately, e.g. right after committing a transaction that inserted events. It never blocks.

func (*Worker) Run added in v0.1.0

func (w *Worker) Run(ctx context.Context) error

Run polls until ctx is cancelled. The in-flight batch is always finished and committed before Run returns, so shutdown never loses state. Returns nil on graceful shutdown.

func (*Worker) RunOnce added in v0.1.0

func (w *Worker) RunOnce(ctx context.Context) (int, error)

RunOnce claims, publishes and commits a single batch. It returns the number of events claimed. Useful for tests, cron jobs and serverless environments.

Directories

Path Synopsis
internal
sqlstore
Package sqlstore holds the dialect-independent SQL store implementation.
Package sqlstore holds the dialect-independent SQL store implementation.
storetest
Package storetest is a conformance suite for SQL stores.
Package storetest is a conformance suite for SQL stores.
Package kafka publishes outbox events to Apache Kafka using IBM/sarama.
Package kafka publishes outbox events to Apache Kafka using IBM/sarama.
Package mysql is an xoutbox.Store for MySQL 8.0+ / MariaDB 10.6+ using database/sql.
Package mysql is an xoutbox.Store for MySQL 8.0+ / MariaDB 10.6+ using database/sql.
Package nats publishes outbox events to NATS JetStream.
Package nats publishes outbox events to NATS JetStream.
Package postgres is an xoutbox.Store for PostgreSQL 12+ using database/sql (works with pgx/stdlib and lib/pq).
Package postgres is an xoutbox.Store for PostgreSQL 12+ using database/sql (works with pgx/stdlib and lib/pq).
Package rabbitmq publishes outbox events to RabbitMQ with publisher confirms, persistent delivery and automatic reconnection.
Package rabbitmq publishes outbox events to RabbitMQ with publisher confirms, persistent delivery and automatic reconnection.
Package redis publishes outbox events to Redis Streams (durable, default) or Redis Pub/Sub (fire-and-forget).
Package redis publishes outbox events to Redis Streams (durable, default) or Redis Pub/Sub (fire-and-forget).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL