pgoutbox

package module
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Jul 6, 2026 License: MIT Imports: 20 Imported by: 0

README

pgoutbox - a transactional outbox for pgx

pgoutbox implements a simple transactional outbox for pgx. New messages can be added to a Postgres table using AddMessages and can be flushed to a destination via ProcessMessages.

Here's an example of flushing messages on topic1 by simply printing them to the console:

type printFlusher struct{}

func (printFlusher) Flush(_ context.Context, msgs []*sqlc.Message) error {
	for _, m := range msgs {
		fmt.Printf("  flushed id=%d topic=%s payload=%s\n", m.ID, m.Topic, string(m.Payload))
	}
	return nil
}

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

outbox, err := pgoutbox.NewOutbox(ctx, pool)

if err != nil {
    panic(err)
}

outbox.AddFlusher("topic1", printFlusher{})

Then, within a transaction, messages can be added via:

if err := outbox.AddMessages(ctx, tx, "topic1", msgs); err != nil {
    panic(err)
}

And messages can be flushed after the transaction commits using:

_, err := outbox.ProcessMessages(ctx, "topic1");

if err != nil {
    panic(err)
}

Schema

By default, NewOutbox runs migrations and creates an outbox table in the schema outbox.messages. This can be overwritten via:

outbox, err := pgoutbox.NewOutbox(ctx, pool, pgoutbox.WithSchema("my_schema"))

If you'd rather run migrations yourself (for example, as part of a separate release step), disable the auto-migration and invoke Migrate explicitly:

outbox, err := pgoutbox.NewOutbox(ctx, pool,
    pgoutbox.WithSchema("my_schema"),
    pgoutbox.WithAutoMigrate(false),
)

if err := pgoutbox.Migrate(ctx, pool, pgoutbox.WithSchema("my_schema")); err != nil {
    panic(err)
}

Multiple topics and flushers

It's easy to configure multiple destinations using topics registered for each flusher:

outbox.AddFlusher("orders", ordersFlusher{})
outbox.AddFlusher("shipments", shipmentsFlusher{})

// within a single transaction, write to whichever topics you need
tx, err := pool.Begin(ctx)
if err != nil {
    panic(err)
}

if err := outbox.AddMessages(ctx, tx, "orders", orderMsgs); err != nil {
    panic(err)
}
if err := outbox.AddMessages(ctx, tx, "shipments", shipmentMsgs); err != nil {
    panic(err)
}

if err := tx.Commit(ctx); err != nil {
    panic(err)
}

// each topic is drained independently by its registered flusher
outbox.ProcessMessages(ctx, "orders")
outbox.ProcessMessages(ctx, "shipments")

Atomic flush and delete

If your flusher writes to Postgres itself (e.g. into a relay table), implement TxFlusher instead of Flusher. ProcessMessages will pass the same transaction it uses to lock and delete messages, so your writes and the outbox delete commit or roll back together:

type relayFlusher struct{ db *pgxpool.Pool }

func (f *relayFlusher) Flush(_ context.Context, _ []*sqlc.Message) error {
    panic("not used; FlushWithTx is called instead")
}

func (f *relayFlusher) FlushWithTx(ctx context.Context, tx pgx.Tx, msgs []*sqlc.Message) error {
    for _, m := range msgs {
        if _, err := tx.Exec(ctx, "INSERT INTO relay (payload) VALUES ($1)", m.Payload); err != nil {
            return err
        }
    }
    return nil
}

Message expiration

Call Start to run background maintenance goroutines that delete old messages. Expiration is configured per topic, with an optional default for topics not explicitly named:

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

outbox, err := pgoutbox.NewOutbox(ctx, pool,
    pgoutbox.WithTopicExpiration("orders", 24*time.Hour),    // orders expire after 24 h
    pgoutbox.WithTopicExpiration("events", 7*24*time.Hour),  // events expire after 7 days
    pgoutbox.WithDefaultExpiration(48*time.Hour),            // all other topics: 48 h
)
if err != nil {
    panic(err)
}

Start always runs a background scanner goroutine that polls the topics table, but maintenance loops are only launched for topics that actually have an expiration configured. Topics don't need to be declared at startup: the library tracks every topic that receives a message in a topics table (via a Postgres trigger) and applies the default expiration automatically.

Multiple outbox instances (e.g. replicas of the same service) coordinate cleanup using a per-topic maintenance lease, so only one instance runs the delete at a time.

To log errors from the maintenance goroutines, pass a zerolog logger:

outbox, err := pgoutbox.NewOutbox(pool,
    pgoutbox.WithTopicExpiration("orders", 24*time.Hour),
    pgoutbox.WithLogger(logger),
)

Lease competition — another instance winning the cleanup race — is not logged.

Exclusive consumers

By default, any number of ProcessMessages callers can drain a topic concurrently (each call grabs a non-overlapping batch using FOR UPDATE SKIP LOCKED). Use AcquireTopic when you need exactly one active consumer at a time.

AcquireTopic blocks until this instance holds the exclusive lease for the topic, then returns. A background goroutine automatically renews the lease until the context is cancelled, at which point the lease expires and another instance can take over:

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// blocks until the lease is acquired; renews in the background until ctx is done
if err := outbox.AcquireTopic(ctx, "orders"); err != nil {
    panic(err)
}

// only this instance may call ProcessMessages for "orders" while ctx is live
_, err = outbox.ProcessMessages(ctx, "orders")

Once a topic has an active exclusive consumer, any other instance calling ProcessMessages for that topic receives pgoutbox.ErrExclusiveLeaseHeld:

if errors.Is(err, pgoutbox.ErrExclusiveLeaseHeld) {
    // another instance owns this topic right now; skip or retry later
}

When the holder's context is cancelled, the lease expires naturally (within the lease duration, 30 s by default) and another instance's AcquireTopic call unblocks.

Benchmarks

You can run benchmarks locally; for example, to write and flush 100k messages, you can run:

go test -bench=. -benchtime=100000x

On a local Macbook with an M3 Max core, this results in 8492 msgs/sec:

$ go test -bench=. -benchtime=100000x
goos: darwin
goarch: arm64
pkg: github.com/hatchet-dev/pgoutbox
cpu: Apple M3 Max
BenchmarkOutbox_WriteAndPublishThroughput-14              100000            117757 ns/op              8492 msgs/sec

Documentation

Index

Constants

This section is empty.

Variables

View Source
var ErrExclusiveLeaseHeld = errors.New("exclusive lease held by another instance")

ErrExclusiveLeaseHeld is returned by ProcessMessages when another outbox instance currently holds a valid exclusive lease for the topic.

Functions

func Migrate

func Migrate(ctx context.Context, pool *pgxpool.Pool, opts ...OutboxOpt) error

Migrate runs the embedded pgoutbox migrations against the given pool. It is the explicit alternative to NewOutbox's auto-migration: callers that want to control when DDL runs (separate startup phase, release pipeline, etc.) should construct the outbox with WithAutoMigrate(false) and invoke Migrate themselves.

Only WithSchema is consulted from opts; other options are accepted for API symmetry but ignored.

Types

type FlushContext added in v0.2.0

type FlushContext interface {
	context.Context
	Tx() pgx.Tx
}

FlushContext is the context passed to Flusher.Flush. It embeds context.Context and exposes the transaction that ProcessMessages uses to lock and delete messages. Callers that want their writes to commit atomically with the outbox delete can enlist in that transaction via Tx().

type Flusher

type Flusher interface {
	Flush(ctx FlushContext, msgs []*sqlc.Message) error
}

type MessageOpts

type MessageOpts struct {
	Payload []byte
}

type NopFlusher added in v0.2.0

type NopFlusher struct{}

func NewNopFlusher added in v0.2.0

func NewNopFlusher() *NopFlusher

func (*NopFlusher) Flush added in v0.2.0

func (f *NopFlusher) Flush(_ FlushContext, _ []*sqlc.Message) error

type Outbox

type Outbox interface {
	AddFlusher(topic string, flusher Flusher)

	AddMessages(ctx context.Context, tx pgx.Tx, topic string, msgs []MessageOpts) error

	// ProcessMessages grabs a batch of messages for the given topic, flushes them using the registered Flusher for that
	// topic, and deletes them from the outbox if the flush is successful. If the topic has an active exclusive consumer,
	// the calling instance must hold the exclusive lease (via AcquireTopic) or an error is returned.
	ProcessMessages(ctx context.Context, topic string, opts ...ProcessOpt) ([]*sqlc.Message, error)

	// AcquireTopic blocks until this instance holds the exclusive processing lease
	// for the named topic, then returns. A background goroutine automatically renews
	// the lease until ctx is cancelled or ReleaseTopic is called, at which point the
	// lease expires naturally and another instance can take over. AcquireTopic must
	// be called before ProcessMessages for any topic that has an active exclusive
	// consumer.
	AcquireTopic(ctx context.Context, topic string) error

	// ReleaseTopic stops renewing and immediately expires the exclusive lease
	// this instance holds for topic, letting another instance acquire it right
	// away instead of waiting out the lease duration. It is a no-op if this
	// instance does not currently hold the lease. As with a naturally expired
	// lease, a subsequent ProcessMessages call still requires an explicit
	// AcquireTopic first.
	ReleaseTopic(ctx context.Context, topic string) error
}

func NewOutbox

func NewOutbox(ctx context.Context, pool *pgxpool.Pool, fs ...OutboxOpt) (Outbox, error)

NewOutbox creates an outbox backed by pool and starts the background maintenance goroutines. The goroutines run until ctx is cancelled; pass a context tied to your application lifetime (e.g. from signal.NotifyContext).

type OutboxOpt

type OutboxOpt func(*outboxImplOpts)

func WithAutoMigrate

func WithAutoMigrate(enabled bool) OutboxOpt

WithAutoMigrate controls whether NewOutbox runs the embedded migrations on construction. Defaults to true. Set to false when the caller wants to run migrations explicitly via Migrate (for example, in a separate startup phase or release pipeline).

func WithDefaultExpiration added in v0.2.0

func WithDefaultExpiration(ttl time.Duration) OutboxOpt

WithDefaultExpiration sets a fallback TTL used for topics that have no specific expiration configured via WithTopicExpiration. Any topic that appears in the topics table with a NULL expiration_nanos will be maintained using this TTL when Start is running.

func WithLogger added in v0.2.0

func WithLogger(l zerolog.Logger) OutboxOpt

WithLogger attaches a zerolog logger that receives error-level messages from the background maintenance goroutines. Lease competition (another instance holding the lease) is not logged. If not set, maintenance errors are silent.

func WithSchema

func WithSchema(searchPath string) OutboxOpt

func WithTopicExpiration added in v0.2.0

func WithTopicExpiration(topic string, ttl time.Duration) OutboxOpt

WithTopicExpiration registers a TTL for the named topic. On Start, the TTL is written to the topics table so that any outbox instance can discover it. Messages older than ttl are eligible for deletion by the background maintenance goroutine launched by Start. Per-topic TTLs take precedence over WithDefaultExpiration.

type ProcessOpt added in v0.2.0

type ProcessOpt func(*processOpts)

ProcessOpt is a per-call option for ProcessMessages.

func WithBatchSize

func WithBatchSize(n int) ProcessOpt

WithBatchSize sets the maximum number of messages ProcessMessages will acquire and hand to the Flusher in a single call. Must be > 0. Values above math.MaxInt32 are ignored and the default (1000) is used instead.

Directories

Path Synopsis
internal
harness
Package harness provides reusable scaffolding for the pgoutbox e2e tests.
Package harness provides reusable scaffolding for the pgoutbox e2e tests.

Jump to

Keyboard shortcuts

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