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 ¶
- Variables
- func Migrate(ctx context.Context, pool *pgxpool.Pool, opts ...OutboxOpt) error
- type AddOpt
- type FlushContext
- type Flusher
- type MessageOpts
- type NopFlusher
- type Notifier
- type Outbox
- type OutboxOpt
- type PGPubSubOpt
- type ProcessOpt
- type PubSub
- type PubSubMessage
- type SubscribeOpt
- type TxPublisher
Constants ¶
This section is empty.
Variables ¶
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.
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 ¶
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
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
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
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 ¶
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 ¶
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
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
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
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 ¶
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
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
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.