pgoutbox

package module
v0.4.1 Latest Latest
Warning

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

Go to latest
Published: Oct 19, 2026 License: MIT Imports: 22 Imported by: 0

README

pgoutbox - a transactional outbox for pgx

Go Reference

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

pgoutbox

Why?

While working on Hatchet we needed a reliable and performant way to durably persist messages over a message boundary using Postgres. In particular, we needed:

Without these particular requirements, a library like River would otherwise have been a good fit. pgoutbox is deliberately an outbox rather than a job queue: there are no retries, scheduling, priorities, or job history, and messages are deleted as soon as they're flushed.

Example usage

For a full working example, see ./examples. Here's an example of flushing messages on topic1 by simply printing them to the console:

type printFlusher struct{}

func (printFlusher) Flush(_ pgoutbox.FlushContext, 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 migrations

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 separate table), use the transaction exposed by the FlushContext passed to Flush. It is the same transaction ProcessMessages uses to lock and delete messages, so your writes and the outbox delete commit or roll back together:

type relayFlusher struct{}

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

FlushContext embeds context.Context, so flushers that don't need the transaction can ignore Tx() and treat it as a plain context.

Continuous processing with Subscribe

Instead of calling ProcessMessages yourself, Subscribe drains the topic automatically by listening for a poll or a new-message notification. It blocks until its context is cancelled:

go func() {
    err := outbox.Subscribe(ctx, "orders",
        pgoutbox.WithPollInterval(5*time.Second),              // default: 5s
        pgoutbox.WithProcessOpts(pgoutbox.WithBatchSize(500)), // forwarded to every ProcessMessages call
    )
    if err != nil && !errors.Is(err, context.Canceled) {
        panic(err)
    }
}()

If processing fails, messages are logged to the WithLogger logger and retried again on the next wake-up. Subscribe returns an error immediately only if no flusher is registered for the topic or the subscription itself can't be established.

Waking on new messages with LISTEN/NOTIFY

Without further configuration, Subscribe is purely poll-based. To wake subscribers the moment new messages commit, attach a PubSub — the built-in implementation rides Postgres LISTEN/NOTIFY:

ps, err := pgoutbox.NewPGPubSub(ctx, pool)
if err != nil {
    panic(err)
}

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

You can swap your own pub/sub implementation as well. Note that a notification only makes sense once the inserting transaction has committed, so a PubSub that doesn't implement TxPublisher needs to defer notifying until after commit. In these cases, pass a Notifier to AddMessages and fire it after a successful commit:

var notifier pgoutbox.Notifier

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

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

notifier.Notify(ctx) // wakes the subscribers of both topics

Delivery is best-effort by design; if notifications are lost, pgoutbox falls back to polling.

The default LISTEN/NOTIFY channel is pgoutbox_pubsub, which can be configured via pgoutbox.WithNotifyChannel("my_channel") if necessary.

Message expiration

NewOutbox starts background maintenance goroutines that delete old messages; they run until the context passed to NewOutbox is cancelled. 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)
}

The outbox 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 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(ctx, pool,
    pgoutbox.WithTopicExpiration("orders", 24*time.Hour),
    pgoutbox.WithLogger(logger),
)

Exclusive consumers

By default, any number of ProcessMessages callers can drain a topic concurrently (using FOR UPDATE SKIP LOCKED). Use AcquireTopic or pgoutbox.WithExclusive() passed to Subscribe 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
}

An instance that never acquired the lease (or whose lease has expired) receives pgoutbox.ErrExclusiveLeaseRequired instead.

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.

You can also pass pgoutbox.WithExclusive() to Subscribe:

// Exactly one instance across the fleet drains "orders" at a time; the rest
// wait in line and take over on failure.
err := outbox.Subscribe(ctx, "orders", pgoutbox.WithExclusive())

With WithExclusive(), Subscribe:

  1. acquires the lease before its first processing pass, blocking while another instance holds it (like AcquireTopic);
  2. re-acquires it automatically if the lease is ever lost mid-subscribe (for example, a heartbeat lapse during a database blip); and
  3. releases it on return, so a waiting instance takes over immediately instead of waiting out the lease's grace period.

Several instances calling Subscribe(..., WithExclusive()) on the same topic therefore form a failover group: one active consumer, the rest hot standbys. Without WithExclusive(), subscribing to a topic whose exclusive lease is held elsewhere doesn't fail — every pass errors (visible via WithLogger) and is retried, so the subscriber sits idle until the lease frees up or is acquired. For an exclusive topic, either call AcquireTopic before Subscribe or pass WithExclusive().

Benchmarks

On a local Macbook with an M3 Max core, pgoutbox reaches 223817 msgs/sec with batching support. You can run benchmarks locally; for example, to write and flush 100k messages, you can run:

go test -bench=. -benchtime=100000x

Full results from a local run:

$ go test -bench=. -benchtime=100000x
goos: darwin
goarch: arm64
pkg: github.com/hatchet-dev/pgoutbox
cpu: Apple M3 Max
BenchmarkOutbox_WriteAndPublishThroughput/Flush/topics=1/batch=1-14         	  100000	    125855 ns/op	      7946 msgs/sec
BenchmarkOutbox_WriteAndPublishThroughput/TxFlush/topics=1/batch=1-14       	  100000	    219546 ns/op	      4555 msgs/sec
BenchmarkOutbox_WriteAndPublishThroughput/Flush/topics=1/batch=10-14        	  100000	     16328 ns/op	     61244 msgs/sec
BenchmarkOutbox_WriteAndPublishThroughput/TxFlush/topics=1/batch=10-14      	  100000	    133446 ns/op	      7494 msgs/sec
BenchmarkOutbox_WriteAndPublishThroughput/Flush/topics=1/batch=100-14       	  100000	      6806 ns/op	    146925 msgs/sec
BenchmarkOutbox_WriteAndPublishThroughput/TxFlush/topics=1/batch=100-14     	  100000	    150929 ns/op	      6626 msgs/sec
BenchmarkOutbox_WriteAndPublishThroughput/Flush/topics=10/batch=1-14        	  100000	    293680 ns/op	      3405 msgs/sec
BenchmarkOutbox_WriteAndPublishThroughput/TxFlush/topics=10/batch=1-14      	  100000	    241538 ns/op	      4140 msgs/sec
BenchmarkOutbox_WriteAndPublishThroughput/Flush/topics=10/batch=10-14       	  100000	     25111 ns/op	     39822 msgs/sec
BenchmarkOutbox_WriteAndPublishThroughput/TxFlush/topics=10/batch=10-14     	  100000	     48755 ns/op	     20511 msgs/sec
BenchmarkOutbox_WriteAndPublishThroughput/Flush/topics=10/batch=100-14      	  100000	      4468 ns/op	    223817 msgs/sec
BenchmarkOutbox_WriteAndPublishThroughput/TxFlush/topics=10/batch=100-14    	  100000	     36192 ns/op	     27631 msgs/sec
BenchmarkOutbox_SubscribeThroughput/Flush/topics=1/batch=1-14               	  100000	    209316 ns/op	      4777 msgs/sec
BenchmarkOutbox_SubscribeThroughput/TxFlush/topics=1/batch=1-14             	  100000	    301901 ns/op	      3312 msgs/sec
BenchmarkOutbox_SubscribeThroughput/Flush/topics=1/batch=10-14              	  100000	     21391 ns/op	     46749 msgs/sec
BenchmarkOutbox_SubscribeThroughput/TxFlush/topics=1/batch=10-14            	  100000	    153971 ns/op	      6495 msgs/sec
BenchmarkOutbox_SubscribeThroughput/Flush/topics=1/batch=100-14             	  100000	      7479 ns/op	    133706 msgs/sec
BenchmarkOutbox_SubscribeThroughput/TxFlush/topics=1/batch=100-14           	  100000	    141189 ns/op	      7083 msgs/sec
BenchmarkOutbox_SubscribeThroughput/Flush/topics=10/batch=1-14              	  100000	    289379 ns/op	      3456 msgs/sec
BenchmarkOutbox_SubscribeThroughput/TxFlush/topics=10/batch=1-14            	  100000	    924623 ns/op	      1082 msgs/sec
BenchmarkOutbox_SubscribeThroughput/Flush/topics=10/batch=10-14             	  100000	     33516 ns/op	     29836 msgs/sec
BenchmarkOutbox_SubscribeThroughput/TxFlush/topics=10/batch=10-14           	  100000	     58475 ns/op	     17101 msgs/sec
BenchmarkOutbox_SubscribeThroughput/Flush/topics=10/batch=100-14            	  100000	      5352 ns/op	    186840 msgs/sec
BenchmarkOutbox_SubscribeThroughput/TxFlush/topics=10/batch=100-14          	  100000	     39021 ns/op	     25627 msgs/sec

Documentation

Overview

Package pgoutbox implements a simple transactional outbox for pgx.

New messages are added to a Postgres table within a transaction using Outbox.AddMessages and are flushed to a destination via Outbox.ProcessMessages. Because the insert rides the caller's own transaction, a message is only ever visible to consumers if the business write it belongs to committed.

Messages are grouped by topic, and each topic is drained by the Flusher registered for it with Outbox.AddFlusher. ProcessMessages locks a batch of messages, hands the whole batch to a single Flush call, and deletes the messages in the same transaction if the flush succeeds. A Flusher that writes to Postgres itself can use FlushContext.Tx so its writes and the outbox delete commit or roll back together.

Outbox.Subscribe drains a topic continuously, waking on a poll interval or, when a PubSub such as NewPGPubSub is attached via WithPubSub, the moment new messages commit. Outbox.AcquireTopic and WithExclusive give a topic exactly one active consumer across a fleet, backed by a renewing lease with automatic failover. WithTopicExpiration and WithDefaultExpiration delete old messages in the background.

A minimal setup looks like:

type printFlusher struct{}

func (printFlusher) Flush(_ pgoutbox.FlushContext, 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
}

outbox, err := pgoutbox.NewOutbox(ctx, pool)
if err != nil {
	return err
}
outbox.AddFlusher("orders", printFlusher{})

// within a transaction
err = outbox.AddMessages(ctx, tx, "orders", []pgoutbox.MessageOpts{{Payload: payload}})

// after the transaction commits
_, err = outbox.ProcessMessages(ctx, "orders")

See the README at https://github.com/hatchet-dev/pgoutbox for a longer walkthrough of each feature.

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 the exclusive lease for the topic.

View Source
var ErrExclusiveLeaseRequired = errors.New("exclusive lease required: call AcquireTopic first")

ErrExclusiveLeaseRequired is returned by ProcessMessages when the topic has an exclusive consumer but this instance does not hold a live lease, either because AcquireTopic was never called or because the lease has expired.

Functions

func Migrate

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

Migrate runs the embedded pgoutbox migrations against pool, creating the schema if needed. NewOutbox runs them automatically by default. To run them yourself instead, for example as part of a separate release step, construct the outbox with WithAutoMigrate(false) and call Migrate explicitly.

Only WithSchema is consulted from opts. Other options are accepted so the same option list can be passed to NewOutbox, but they are ignored here.

Types

type AddOpt added in v0.4.0

type AddOpt func(*addOpts)

AddOpt is a per-call option for AddMessages.

func WithNotifier added in v0.4.0

func WithNotifier(n *Notifier) AddOpt

WithNotifier has AddMessages record its new-message notification in n so the caller can publish it after the transaction commits. It is only needed for PubSub implementations that don't implement TxPublisher, since those can't defer publishing to commit time. One Notifier can be shared by every AddMessages call in a transaction and fired once with Notify after commit.

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, so flushers that don't need the transaction can treat it as a plain context. Tx returns the transaction ProcessMessages uses to lock and delete messages. Flushers that write to Postgres themselves can use it so their writes and the outbox delete commit or roll back together.

type Flusher

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

Flusher delivers a batch of messages to their destination. Register one per topic with Outbox.AddFlusher. ProcessMessages calls Flush with the messages it has locked for the topic and deletes them only if Flush returns nil. If Flush returns an error, the messages stay in the outbox and are retried on the next call.

Flushers that write to Postgres can use ctx.Tx() so that their writes and the outbox delete commit or roll back together.

type MessageOpts

type MessageOpts struct {
	// Payload is the opaque message body. It is stored as-is and handed back to
	// the Flusher unchanged.
	Payload []byte
}

MessageOpts describes a single message to add via Outbox.AddMessages.

type NopFlusher added in v0.2.0

type NopFlusher struct{}

NopFlusher is a Flusher that discards every message it is given. It is useful in tests, and for draining a topic whose messages are no longer needed.

func NewNopFlusher added in v0.2.0

func NewNopFlusher() *NopFlusher

NewNopFlusher returns a NopFlusher.

func (*NopFlusher) Flush added in v0.2.0

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

Flush discards msgs and returns nil.

type Notifier added in v0.4.0

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

Notifier accumulates the new-message notifications from the AddMessages calls it is passed to via WithNotifier, so they can be published once the transaction has committed. The zero value is ready to use. Like the pgx.Tx it accompanies, it is not safe for concurrent use.

func (*Notifier) Notify added in v0.4.0

func (n *Notifier) Notify(ctx context.Context)

Notify publishes the accumulated notifications. Call it once after the transaction commits successfully. After a rollback, discard the Notifier instead. Notify is a no-op when there is nothing to publish, including when the PubSub implements TxPublisher and already published on the transaction.

Delivery is best-effort by design: failures are logged to the WithLogger logger rather than returned, since subscribers fall back to polling and pick the messages up regardless. Calling Notify more than once only repeats the wake-ups, which is harmless.

type Outbox

type Outbox interface {
	// AddFlusher registers the Flusher that ProcessMessages and Subscribe use to
	// drain topic. Registering a flusher for a topic that already has one
	// replaces it.
	AddFlusher(topic string, flusher Flusher)

	// AddMessages adds msgs to the topic within the caller's transaction. The
	// messages become visible to ProcessMessages once tx commits, and are
	// discarded if it rolls back.
	//
	// When the outbox was built with WithPubSub, AddMessages also notifies
	// subscribers of the topic. A TxPublisher such as NewPGPubSub publishes the
	// notification inside tx, so it is delivered exactly when the transaction
	// commits. Other PubSub implementations need to defer the notification
	// until after commit: pass a Notifier via WithNotifier and call Notify once
	// the transaction has committed. Skipping the notification never loses
	// messages; subscribers pick them up on their next poll.
	AddMessages(ctx context.Context, tx pgx.Tx, topic string, msgs []MessageOpts, opts ...AddOpt) error

	// ProcessMessages grabs a batch of messages for the topic, flushes them
	// using the registered Flusher, and deletes them from the outbox in the
	// same transaction if the flush succeeds. It returns the flushed messages,
	// or nil if the topic was empty. The batch size defaults to 1000 and can be
	// changed with WithBatchSize.
	//
	// Messages are locked with FOR UPDATE SKIP LOCKED, so any number of callers
	// can drain a topic concurrently. If the topic has an active exclusive
	// consumer, the caller must hold the lease via AcquireTopic; otherwise
	// ErrExclusiveLeaseHeld or ErrExclusiveLeaseRequired is returned.
	ProcessMessages(ctx context.Context, topic string, opts ...ProcessOpt) ([]*sqlc.Message, error)

	// Subscribe drains the topic automatically by listening for a poll or a
	// new-message notification. It runs ProcessMessages until the topic is
	// empty, then waits for the poll interval (see WithPollInterval) or, when
	// the outbox was built with WithPubSub, for a new-message notification, and
	// drains again. It blocks until ctx is cancelled and then returns ctx.Err().
	//
	// If processing fails, the error is logged to the WithLogger logger and the
	// messages are retried on the next wake-up. Subscribe returns an error
	// immediately only if no flusher is registered for the topic, the PubSub
	// subscription can't be established, or the initial lease acquisition for
	// WithExclusive fails.
	//
	// As with ProcessMessages, a topic with an active exclusive consumer
	// requires the lease. Either call AcquireTopic before Subscribe or pass
	// WithExclusive to have Subscribe manage the lease itself.
	Subscribe(ctx context.Context, topic string, opts ...SubscribeOpt) error

	// AcquireTopic blocks until this instance holds the exclusive lease for the
	// topic, then returns. A background goroutine automatically renews the
	// lease until ctx is cancelled or ReleaseTopic is called, at which point
	// the lease expires and another instance can take over.
	//
	// Once a topic has an exclusive consumer, only the lease holder may call
	// ProcessMessages for it. Other instances receive ErrExclusiveLeaseHeld, and
	// an instance whose lease has expired receives ErrExclusiveLeaseRequired
	// until it calls AcquireTopic again.
	AcquireTopic(ctx context.Context, topic string) error

	// ReleaseTopic stops renewing the exclusive lease this instance holds for
	// topic and expires it immediately, so a waiting instance can take over
	// right away instead of waiting out the lease duration. It is a no-op if
	// this instance does not hold the lease. A later ProcessMessages call for
	// the topic requires AcquireTopic again.
	ReleaseTopic(ctx context.Context, topic string) error
}

Outbox is a transactional outbox: messages are added to a Postgres table within the caller's transaction and later flushed to a destination by the Flusher registered for their topic. Create one with NewOutbox.

Topics are plain strings and don't need to be declared up front. Any number of topics can be used, and every topic that receives a message is tracked automatically.

func NewOutbox

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

NewOutbox creates an outbox backed by pool. By default it runs the embedded migrations, creating the outbox tables in the "outbox" schema (see WithSchema and WithAutoMigrate), and starts the background maintenance goroutines that delete expired messages. The goroutines run until ctx is cancelled, so pass a context tied to your application lifetime.

type OutboxOpt

type OutboxOpt func(*outboxImplOpts)

OutboxOpt configures NewOutbox and Migrate.

func WithAutoMigrate

func WithAutoMigrate(enabled bool) OutboxOpt

WithAutoMigrate controls whether NewOutbox runs the embedded migrations. Defaults to true. Set it to false to run migrations yourself by calling Migrate, for example as part of a separate release step.

func WithDefaultExpiration added in v0.2.0

func WithDefaultExpiration(ttl time.Duration) OutboxOpt

WithDefaultExpiration sets the expiration for topics that have none configured via WithTopicExpiration. Topics don't need to be declared at startup: every topic that receives a message is tracked in the topics table and picks up the default automatically. Without a default, topics that have no explicit expiration are never expired.

func WithLogger added in v0.2.0

func WithLogger(l zerolog.Logger) OutboxOpt

WithLogger attaches a zerolog logger that receives errors from the background maintenance goroutines, from Subscribe's processing passes, and from Notifier.Notify. If not set, those errors are silent.

func WithPubSub added in v0.4.0

func WithPubSub(ps PubSub) OutboxOpt

WithPubSub attaches a PubSub that wakes Subscribe callers the moment new messages commit, instead of waiting out the poll interval. NewPGPubSub provides an implementation built on Postgres LISTEN/NOTIFY.

Delivery is best-effort by design; if a notification is lost, Subscribe falls back to polling. If ps implements TxPublisher, the notification is published inside the AddMessages transaction. Otherwise, pass a Notifier to AddMessages via WithNotifier and call Notify after commit.

func WithSchema

func WithSchema(schema string) OutboxOpt

WithSchema sets the Postgres schema that holds the outbox tables. Defaults to "outbox". The schema is created if it doesn't exist when migrations run.

func WithTopicExpiration added in v0.2.0

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

WithTopicExpiration sets how long messages on topic are kept before the background maintenance goroutines delete them. NewOutbox writes the expiration to the topics table so that every outbox instance can discover it. Per-topic expirations take precedence over WithDefaultExpiration.

type PGPubSubOpt added in v0.4.0

type PGPubSubOpt func(*pgPubSubOpts)

PGPubSubOpt configures the PubSub returned by NewPGPubSub.

func WithNotifyChannel added in v0.4.0

func WithNotifyChannel(name string) PGPubSubOpt

WithNotifyChannel sets the Postgres LISTEN/NOTIFY channel that carries the PubSub's messages. Defaults to "pgoutbox_pubsub". Two outboxes sharing a database should use distinct channels so they don't wake each other's subscribers.

func WithNotifyLogger added in v0.4.0

func WithNotifyLogger(l zerolog.Logger) PGPubSubOpt

WithNotifyLogger attaches a zerolog logger that receives errors from the background listener, such as connection failures and malformed payloads. If not set, those errors are silent.

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 locks and hands to the Flusher in a single call. Defaults to 1000. Values that are not positive or that exceed math.MaxInt32 are ignored.

type PubSub added in v0.4.0

type PubSub interface {
	// Pub publishes payload to topic.
	Pub(ctx context.Context, topic string, payload []byte) error

	// Sub subscribes to topic and returns a channel of messages published to
	// it. The subscription lasts until ctx ends (or the PubSub itself shuts
	// down), at which point the channel is closed. The channel should be
	// buffered; implementations may drop messages rather than block when a
	// slow consumer's buffer is full.
	Sub(ctx context.Context, topic string) (<-chan *PubSubMessage, error)
}

PubSub is a minimal publish/subscribe transport for small notification messages. The outbox uses it via WithPubSub to wake Subscribe callers as soon as new messages commit, instead of waiting out the poll interval. NewPGPubSub provides an implementation built on Postgres LISTEN/NOTIFY, and you can swap in your own transport by implementing this interface.

Delivery is best-effort by design. Implementations may drop messages under load or while disconnected: the outbox falls back to polling for lost messages, and an extra processing pass on an empty topic is a no-op, so duplicate or spurious messages are harmless.

func NewPGPubSub added in v0.4.0

func NewPGPubSub(ctx context.Context, pool *pgxpool.Pool, fs ...PGPubSubOpt) (PubSub, error)

NewPGPubSub returns a PubSub backed by Postgres LISTEN/NOTIFY on the given pool. Pass it to WithPubSub to wake subscribers the moment new messages commit. The background listener starts on the first Sub call and runs until ctx is cancelled, so pass a context tied to your application lifetime.

The returned PubSub implements TxPublisher, so notifications are published inside the AddMessages transaction: subscribers wake when it commits, and not at all if it rolls back.

Postgres caps NOTIFY payloads at roughly 8000 bytes, and Pub returns an error beyond that. The outbox's own notifications carry no payload.

type PubSubMessage added in v0.4.0

type PubSubMessage struct {
	// Topic is the pub/sub topic the message was published to.
	Topic string `json:"topic"`

	// Payload is the opaque message body. It may be nil: the outbox's own
	// new-message notifications carry no payload, since the notification
	// itself is the signal to check the outbox.
	Payload []byte `json:"payload,omitempty"`
}

PubSubMessage is a single message delivered by a PubSub subscription.

type SubscribeOpt added in v0.4.0

type SubscribeOpt func(*subscribeOpts)

SubscribeOpt is a per-call option for Subscribe.

func WithExclusive added in v0.4.0

func WithExclusive() SubscribeOpt

WithExclusive has Subscribe manage the topic's exclusive lease for the duration of the call. Subscribe then acquires the lease before its first processing pass, blocking while another instance holds it (like AcquireTopic); re-acquires it automatically if it is ever lost mid-subscribe; and releases it on return, so a waiting instance takes over immediately instead of waiting out the lease duration.

Several instances calling Subscribe with WithExclusive on the same topic therefore form a failover group: one active consumer, the rest hot standbys.

func WithPollInterval added in v0.4.0

func WithPollInterval(d time.Duration) SubscribeOpt

WithPollInterval sets how long Subscribe waits between processing passes when no new-message notification arrives. Defaults to 5 seconds. Values that are not positive are ignored.

func WithProcessOpts added in v0.4.0

func WithProcessOpts(popts ...ProcessOpt) SubscribeOpt

WithProcessOpts forwards ProcessMessages options such as WithBatchSize to every processing pass Subscribe makes.

type TxPublisher added in v0.4.0

type TxPublisher interface {
	PubInTx(ctx context.Context, tx pgx.Tx, topic string, payload []byte) error
}

TxPublisher is an optional interface a PubSub can implement to publish within a pgx transaction. A notification only makes sense once the inserting transaction has committed. When the PubSub passed to WithPubSub implements TxPublisher, AddMessages publishes its notification inside the caller's transaction, so it is delivered exactly when the insert commits and never for a transaction that rolls back. A PubSub that doesn't implement it needs to defer notifying until after commit; see WithNotifier.

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