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

outbox

package module
v0.0.4 Latest Latest
Warning

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

Go to latest
Published: Oct 5, 2026 License: BSD-3-Clause Imports: 7 Imported by: 0

README

Postgres Outbox

A PostgreSQL-backed outbox implementation for reliable, transactional event publishing built on dbtx.

The outbox pattern lets you write business data and domain events in the same database transaction. Events are stored in dbtx.outbox and later dispatched by workers. The package handles visibility, retries with backoff, and a dead-letter emulation without external dependencies.

Use cases

  • Transactional outbox – enqueue domain events inside the same transaction as your aggregate writes. If the transaction rolls back, no event is emitted.
  • At-least-once delivery – workers Dequeue visible messages, process them, and acknowledge by returning nil. Failures are retried with a backoff.
  • Retry with limits – set MaxAttempts per message (default 10). Once a message runs out of attempts, it is dead.
  • Delayed visibility – set AvailableAt to schedule future processing.
  • Dead letters – wrap the handler error with outbox.ErrDeadLetter to stop further retries. Inspect, requeue and purge dead messages with DeadLetters, Requeue and Purge.

Schema

The schema lives in repository/schema.sql and is exported as repository.Schema. It is idempotent, so you can run it at startup or copy it into your migrations:

_, err := db.ExecContext(ctx, repository.Schema)

A message moves through these statuses:

pending ──Dequeue──▶ processing ──nil──▶ done
   ▲                     │
   └──error, attempts────┤
      left               └──error, no attempts left / ErrDeadLetter──▶ dead

The package expects PostgreSQL with uuidv7() support (18+).

Install

go get github.com/alextanhongpin/dbtx/postgres/outbox

Quick start

repo := repository.New(db) // db is a *sql.DB
o := outbox.New(repo)

// Enqueue inside the same transaction as your business write.
err := repo.RunInTx(ctx, func(ctx context.Context) error {
    // ... write aggregates using the tx in ctx ...

    _, err := o.Enqueue(ctx, outbox.EnqueueParams{
        AggregateID:   "order-123",
        AggregateType: "Order",
        EventType:     "OrderCreated",
        Payload:       jsontext.Value(`{"id":"order-123"}`),
        MaxAttempts:   5,                          // optional, defaults to 10
        AvailableAt:   time.Now().Add(time.Minute), // optional delay
    })
    return err
})

Enqueue joins the transaction in ctx when it was started by a dbtx.DB with the same ID as the repository. Both default to dbtx.ID, so any dbtx.New(db).RunInTx works. Called without a transaction, Enqueue commits the message on its own, which loses the outbox guarantee.

Dequeue worker
for {
    err := o.Dequeue(ctx, 10, func(ctx context.Context, msg *outbox.Message) error {
        return publish(ctx, msg)
    })
    if errors.Is(err, outbox.ErrEOQ) {
        time.Sleep(100 * time.Millisecond)
        continue
    }
    if err != nil {
        log.Printf("dequeue: %v", err)
    }
}

Dequeue leases up to limit messages to the worker (FOR UPDATE SKIP LOCKED), so several workers can run concurrently. It does not keep a transaction open while the batch waits: each message is handled in its own short transaction. Leased messages are hidden for o.Lease (default 30s), which must cover handling the whole batch. If a lease expires, for example because the worker crashed, the message is delivered again.

Failure semantics

Return nil to acknowledge the message. It is marked as done in the same transaction as the writes the handler made through ctx. If the lease expired in the meantime, the transaction is rolled back and ErrLeaseExpired is returned.

Return any error to fail it. Writes the handler made through ctx are rolled back, the error is stored in last_error, and the message is hidden until its backoff expires. Backoff times are computed by the database, so clock skew between app and database does not matter.

By default the backoff doubles on each attempt from 1s up to 1h, with jitter. Override it with o.Backoff.

Wrap the error with outbox.ErrDeadLetter to stop retrying:

return fmt.Errorf("%w: invalid payload: %w", outbox.ErrDeadLetter, err)

Handler errors are recorded on the message, not returned from Dequeue. An error from Dequeue other than ErrEOQ means the affected messages will be retried once their lease expires.

A panic in the handler rolls back its transaction and propagates. The message is retried once its lease expires.

Dead letters and cleanup
dead, err := o.DeadLetters(ctx, 100) // oldest first
err = o.Requeue(ctx, dead[0].ID)     // reset attempts, visible now

week := time.Now().Add(-7 * 24 * time.Hour)
n, err := o.Purge(ctx, outbox.StatusDone, week)
n, err = o.Purge(ctx, outbox.StatusDead, week)

Done and dead messages stay in the table until purged. Call Purge periodically.

Inspection
n, err := o.Count(ctx) // visible, retryable messages

Notes

  • Keep processing idempotent – the same message may be redelivered if a worker crashes after publishing but before commit, or if its lease expires.
  • Messages are dequeued in available_at order, so retried messages go behind fresh ones.

Documentation

Overview

Package outbox implements the transactional outbox pattern on PostgreSQL.

Messages are enqueued in the same transaction as the business writes that produce them, and later dispatched by workers that call Dequeue.

Enqueue joins the caller's transaction when ctx carries one created by a dbtx.DB with the same ID as the repository (dbtx.ID by default). Otherwise the message is committed on its own.

Dequeue leases messages to the worker instead of holding row locks, so no transaction stays open while messages wait to be handled. A message whose lease expires, for example because the worker crashed, is delivered again. Delivery is at least once, so handlers must be idempotent.

Index

Constants

View Source
const (
	StatusPending    = "pending"
	StatusProcessing = "processing"
	StatusDone       = "done"
	StatusDead       = "dead"
)

Statuses of a message.

View Source
const DefaultLease = 30 * time.Second

DefaultLease is how long Dequeue leases messages when Outbox.Lease is zero.

Variables

View Source
var (
	ErrDeadLetter         = errors.New("outbox: dead letter")
	ErrEOQ                = errors.New("outbox: end of queue")
	ErrInvalidMaxAttempts = errors.New("outbox: max attempts must not be negative")
	ErrLeaseExpired       = errors.New("outbox: lease expired")
	ErrNotFound           = errors.New("outbox: not found")
	ErrTxInContext        = errors.New("outbox: ctx must not carry a transaction")
)

Functions

func DefaultBackoff

func DefaultBackoff(msg *Message) time.Duration

DefaultBackoff doubles the delay on every attempt, starting from 1s and capped at 1h, with jitter.

Types

type EnqueueParams

type EnqueueParams struct {
	AggregateID   string
	AggregateType string
	EventType     string
	Payload       jsontext.Value

	// MaxAttempts is the number of deliveries before the message is dead.
	// Zero means the default of 10.
	MaxAttempts int32

	// AvailableAt delays the first delivery. Zero means now.
	AvailableAt time.Time
}

type HandlerFunc

type HandlerFunc = func(ctx context.Context, msg *Message) error

HandlerFunc processes a message. The ctx carries the transaction that acknowledges the message.

type Message

type Message struct {
	ID            uuid.UUID
	AggregateType string
	AggregateID   string
	EventType     string
	Payload       jsontext.Value
	Status        string
	Attempts      int32
	MaxAttempts   int32
	LastError     string
	CreatedAt     time.Time
	UpdatedAt     time.Time
}

type Outbox

type Outbox struct {

	// Backoff returns how long a failed message stays hidden before it is
	// retried. Defaults to DefaultBackoff.
	Backoff func(msg *Message) time.Duration

	// Lease is how long dequeued messages stay hidden from other workers.
	// Each message's lease is renewed when its handler starts, and the
	// message cannot be reclaimed while the handler runs. A message whose
	// lease expires while it waits for its turn in the batch may be
	// reclaimed by another worker, and is then skipped. Defaults to
	// DefaultLease.
	Lease time.Duration
	// contains filtered or unexported fields
}

func New

func New(repo Repository) *Outbox

func (*Outbox) Count

func (o *Outbox) Count(ctx context.Context) (int64, error)

Count returns the number of visible messages.

func (*Outbox) DeadLetters

func (o *Outbox) DeadLetters(ctx context.Context, limit int32) ([]*Message, error)

DeadLetters returns up to limit dead messages, oldest first.

func (*Outbox) Dequeue

func (o *Outbox) Dequeue(ctx context.Context, limit int32, fn HandlerFunc) (err error)

Dequeue leases up to limit visible messages and passes each to fn, in its own transaction.

If fn returns nil, the message is marked as done in the same transaction as the writes made by fn. If fn returns an error, the writes made by fn are rolled back, and the message is hidden until its backoff expires. The message is dead once it runs out of attempts, or when the error wraps ErrDeadLetter.

Errors from fn are recorded on the message, and not returned. A message that another worker reclaimed before its turn is skipped with ErrLeaseExpired. If ctx is cancelled, or fn panics, the messages not handled yet are released without using up an attempt. It returns ErrEOQ if there are no visible messages, and ErrTxInContext if ctx already carries a transaction of the repository.

func (*Outbox) Enqueue

func (o *Outbox) Enqueue(ctx context.Context, params EnqueueParams) (uuid.UUID, error)

Enqueue enqueues a new message to outbox.

func (*Outbox) Purge

func (o *Outbox) Purge(ctx context.Context, status string, before time.Time) (int64, error)

Purge deletes messages with the given status, StatusDone or StatusDead, last updated before t, and returns the number of messages deleted. Call it periodically to keep the table small.

func (*Outbox) Requeue

func (o *Outbox) Requeue(ctx context.Context, id uuid.UUID) error

Requeue makes a dead message visible again, with its attempts reset. It returns ErrNotFound if the message is not dead.

type Repository

type Repository interface {
	Ack(ctx context.Context, id uuid.UUID, lockedBy string) error
	Count(ctx context.Context) (int64, error)
	Create(ctx context.Context, params EnqueueParams) (uuid.UUID, error)
	Dead(ctx context.Context) (int64, error)
	IsTx(ctx context.Context) bool
	ListDead(ctx context.Context, limit int32) ([]*Message, error)
	Lock(ctx context.Context, id uuid.UUID, lockedBy string, lease time.Duration) error
	Nack(ctx context.Context, id uuid.UUID, lockedBy string, dead bool, lastError string, delay time.Duration) error
	Poll(ctx context.Context, lockedBy string, limit int32, lease time.Duration) ([]*Message, error)
	Purge(ctx context.Context, status string, before time.Time) (int64, error)
	Release(ctx context.Context, id uuid.UUID, lockedBy string) error
	Requeue(ctx context.Context, id uuid.UUID) error
	RunInSubTx(ctx context.Context, fn func(txCtx context.Context) error) error
	RunInTx(ctx context.Context, fn func(txCtx context.Context) error) error
}

Repository stores outbox messages.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

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