pgoutbox

package module
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Jul 29, 2026 License: MIT Imports: 22 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(_ 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

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), 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 runs it in a loop: it drains the topic, then waits until either the poll interval elapses or a new-message notification arrives (see below), and drains again. 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)
    }
}()

Processing errors don't kill the loop — they're logged to the WithLogger logger and retried 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))

With a PubSub attached, AddMessages publishes a notification for each staged topic and Subscribe wakes on it instead of waiting out the poll interval. The pg-backed PubSub publishes inside the AddMessages transaction, so the notification is delivered exactly when the insert commits — and never for a transaction that rolls back. Postgres deduplicates identical notifications within a transaction, so any number of AddMessages calls for a topic in one transaction cost a single wake-up.

A notification only makes sense once the staging transaction has committed, and a PubSub that doesn't implement TxPublisher has no way to defer a publish to commit time. For those transports, 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

The Notifier accumulates one notification per AddMessages call it is passed to, so a single one can serve a whole transaction. With a TxPublisher transport like the pg-backed PubSub, Notify is a no-op (the notification already rode the transaction), so the pattern is transport-agnostic. Skipping it never loses messages — subscribers just fall back to the poll interval — and publish failures inside Notify are logged, not returned, for the same reason.

Delivery is best-effort by design: if a notification is lost (for example while the listener reconnects), polling picks the messages up within one poll interval. The listener occupies a single dedicated connection (hijacked out of the pool so it doesn't consume a pool slot) no matter how many topics are subscribed.

Two details worth knowing:

  • All notifications travel over one NOTIFY channel, pgoutbox_pubsub by default. Two outboxes sharing a database (e.g. different schemas) should use distinct channels via pgoutbox.WithNotifyChannel("my_channel") to avoid waking each other's subscribers.
  • PubSub is an interface, so you can bring your own transport (e.g. Redis, NATS) instead of LISTEN/NOTIFY. If your implementation also implements TxPublisher (detected once, at NewOutbox), notifications are published transactionally as described above; otherwise use WithNotifier and fire Notify after commit to publish best-effort.

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 (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(ctx, 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
}

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.

Exclusive subscribers

Subscribe composes with exclusive consumers: pass WithExclusive() and it manages the lease for you.

// 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. Nothing is missed during a handoff — the new holder's first drain pass covers any backlog that accumulated while the lease changed hands.

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

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

go test -bench=. -benchtime=100000x

BenchmarkOutbox_WriteAndPublishThroughput drains each topic with a busy-polling ProcessMessages loop; BenchmarkOutbox_SubscribeThroughput drains each topic with a Subscribe call woken by LISTEN/NOTIFY (its poll interval is set far above the benchmark runtime, so throughput is carried entirely by notifications — and each producer commit pays the in-transaction pg_notify). Both run a matrix of 1 and 10 topics (messages spread round-robin, one consumer per topic) and producer batch sizes of 1, 10, and 100 messages per AddMessages transaction. On a local Macbook with an M3 Max core:

$ 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

Batching producer writes is the single biggest lever: staging 100 messages per transaction reaches ~150-220k msgs/sec on the cheap-flusher path, roughly 20× the one-message-per-transaction rate. The batch=1 cells run long enough to be sensitive to background database noise (checkpoints, autovacuum), so expect swings between runs — the 1082 msgs/sec outlier above measures ~3300 msgs/sec in isolation.

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.

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

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

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 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 collect its post-commit notification into n instead of dropping it. Only generic (non-TxPublisher) PubSubs need it — they have no way to defer a publish to commit time, so the caller carries the notification past the transaction and fires it with Notify. One Notifier can be shared by every AddMessages call in a transaction and fired once 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 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 Notifier added in v0.4.0

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

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

func (*Notifier) Notify added in v0.4.0

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

Notify publishes the accumulated notifications. Invoke it once, after the transaction commits successfully; after a rollback, simply discard the Notifier. It is a no-op when there is nothing to publish — no PubSub configured, no messages staged, or a TxPublisher transport that already published on the transaction. Publishing is best-effort: failures are logged to the WithLogger logger, not returned, since durably staged messages are picked up by Subscribe's polling fallback regardless. Calling it more than once just repeats the wake-ups (harmless, like any spurious notification).

type Outbox

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

	// AddMessages stages msgs on the topic within the caller's transaction.
	// When a PubSub is configured, it also arranges the new-message
	// notification that wakes Subscribe callers: TxPublisher transports
	// publish it on tx itself, and generic transports hand it to the Notifier
	// passed via WithNotifier, for the caller to fire after commit (see
	// Notifier). Skipping the option never loses messages, it only leaves
	// generic transports waiting out Subscribe's poll interval.
	AddMessages(ctx context.Context, tx pgx.Tx, topic string, msgs []MessageOpts, opts ...AddOpt) 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)

	// Subscribe blocks and continuously drains the topic: it runs
	// ProcessMessages until the topic is empty, then waits for the poll
	// interval to elapse — or, when the outbox was built with WithPubSub, for
	// a new-message notification — and drains again. Processing errors are
	// logged to the WithLogger logger and retried on the next wake-up; as
	// with ProcessMessages, topics with an active exclusive consumer require
	// AcquireTopic first — either call it beforehand, or pass WithExclusive
	// to have Subscribe acquire, re-acquire, and release the lease itself.
	// Returns ctx.Err() when ctx ends, or an error immediately if no flusher
	// is registered for the topic, the PubSub subscription cannot be
	// established, or the WithExclusive initial acquisition fails.
	Subscribe(ctx context.Context, topic string, opts ...SubscribeOpt) 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 WithPubSub added in v0.4.0

func WithPubSub(ps PubSub) OutboxOpt

WithPubSub attaches a PubSub used to cut end-to-end latency: AddMessages publishes a notification for each staged topic and Subscribe wakes on those notifications instead of waiting out its poll interval. Delivery is best-effort — Subscribe's polling remains the fallback for lost notifications. If ps also implements TxPublisher (NewPGPubSub does), the notification is published inside the AddMessages transaction and delivered exactly when it commits; otherwise pass a Notifier to AddMessages via WithNotifier and invoke Notify after committing.

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 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 overrides the Postgres NOTIFY channel the PubSub multiplexes over. All messages on a channel are broadcast to every listener of that channel, so two outboxes sharing a database (e.g. different schemas) should use distinct channels to avoid spurious wake-ups.

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 (connection failures, 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 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.

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 are staged, instead of waiting out a poll interval.

Delivery is expected to be best-effort: implementations may drop messages under load or while disconnected. The outbox tolerates both lost messages (Subscribe falls back to polling) and duplicate or spurious messages (an extra processing pass on an empty topic is a no-op).

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. The background listener starts lazily on the first Sub call and runs until ctx is cancelled; pass a context tied to your application lifetime.

The returned PubSub implements TxPublisher, so an outbox configured with it publishes new-message notifications transactionally: subscribers wake when the staging transaction commits, and not at all if it rolls back.

NOTIFY payloads are capped by Postgres at roughly 8000 bytes; Pub returns an error beyond that. The outbox's own notifications are empty.

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 makes Subscribe manage the topic's exclusive-consumer lease for the duration of the call: it acquires the lease before the first processing pass (blocking, like AcquireTopic, while another instance holds it), re-acquires it if it is ever lost mid-subscribe, and releases it on return so a waiting instance can take over immediately instead of waiting out the lease's grace period. Several instances calling Subscribe with WithExclusive on the same topic therefore form a failover group: exactly one drains the topic while the rest block in line behind the lease.

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. Must be > 0.

func WithProcessOpts added in v0.4.0

func WithProcessOpts(popts ...ProcessOpt) SubscribeOpt

WithProcessOpts forwards per-call ProcessMessages options (e.g. 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. When the PubSub configured via WithPubSub implements it (detected once, at NewOutbox), AddMessages publishes its new-message notification inside the caller's transaction, so the notification is delivered exactly when the insert commits — and never for a transaction that rolls back. Without it, the notification is deferred to a Notifier the caller passes via WithNotifier and invokes after commit.

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