Documentation
¶
Overview ¶
Package outbox is a transactional outbox for PostgreSQL: a service publishes an event if and only if the database transaction that caused it commits.
Writing ¶
Enqueue writes a Message to the outbox table through the caller's own transaction, the one from go-postgres's RunInTx or RunInTxQuerier. The row commits or rolls back with the business change, so a rolled-back transaction never publishes anything and a committed one always does. Enqueue also queues a NOTIFY, which PostgreSQL delivers only on commit.
Relaying ¶
A Relay, built with NewRelay, claims due rows in batches with FOR UPDATE SKIP LOCKED and stamps each with a lease, then publishes them through a Publisher outside any transaction and marks each one sent. Any number of relays, in any number of processes, can drain one table: SKIP LOCKED and the lease keep two relays from claiming the same row. A relay sleeps between cycles until a notification or the poll interval wakes it.
Guarantees ¶
- At least once. A relay that dies between publishing and marking leaves its rows leased; when the lease runs out another relay publishes them again. Every attempt carries the same idempotency key (Message.Key), and consumers deduplicate on it, with Dedupe or their own store.
- Retries. A failed publish is retried after an exponential, jittered backoff. After the configured number of attempts, or at once for an error wrapped with Permanent, the row becomes a dead letter. Redrive puts dead letters back.
- Ordering. Messages that share an AggregateKey are published one at a time in enqueue order: a row is claimable only while no older row of its aggregate is still pending. A dead letter stops blocking its aggregate. Messages without an AggregateKey have no ordering.
- Graceful shutdown. When Run's context ends the relay stops claiming, lets in-flight publishes finish within the drain timeout and records them, and releases the rows it had claimed but not started.
Schema ¶
Migrate creates the outbox table, an inbox table for Dedupe, and a table that records applied schema versions, all named after WithTable (default "outbox"). SQL returns the same statements for an external migration tool. PostgreSQL 13 or later is required.
Publishers and observability ¶
Publisher is a single method, so any broker fits. The rabbitmq sub-package is a ready one for RabbitMQ on go-rabbitmq, with publisher confirms. The outbox logs through a go-log Logger (WithLogger) and reports relay events to a Metrics hook (WithMetrics); Stats gives counts and lag for gauges and health checks.
Example ¶
package main
import (
"context"
"time"
log "github.com/Bugs5382/go-log"
outbox "github.com/Bugs5382/go-outbox"
outboxrabbitmq "github.com/Bugs5382/go-outbox/rabbitmq"
postgres "github.com/Bugs5382/go-postgres"
rabbitmq "github.com/Bugs5382/go-rabbitmq"
"github.com/jackc/pgx/v5"
)
func main() {
ctx := context.Background()
db, err := postgres.New(ctx, "postgres://app:secret@localhost:5432/app")
if err != nil {
return
}
defer db.Close()
ob, err := outbox.New(outbox.WithTable("app_outbox"), outbox.WithLogger(log.NewLogger("orders")))
if err != nil {
return
}
if err := ob.Migrate(ctx, db); err != nil {
return
}
// The order and its event commit together, or not at all.
_ = db.RunInTx(ctx, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `INSERT INTO orders (id) VALUES ($1)`, 42); err != nil {
return err
}
_, err := ob.Enqueue(ctx, tx, outbox.Message{
Topic: "order.created",
AggregateKey: "order-42",
Payload: []byte(`{"id":42}`),
ContentType: "application/json",
})
return err
})
conn, err := rabbitmq.Connect(ctx, "amqp://app:secret@localhost:5672/")
if err != nil {
return
}
defer func() { _ = conn.Close() }()
pub := outboxrabbitmq.New(conn, "events")
defer func() { _ = pub.Close() }()
relay := ob.NewRelay(db, pub, outbox.WithMaxAttempts(8))
runCtx, stop := context.WithTimeout(ctx, time.Minute)
defer stop()
_ = relay.Run(runCtx)
}
Output:
Index ¶
- Constants
- Variables
- func IsPermanent(err error) bool
- func Permanent(err error) error
- type Message
- type Metrics
- type NopMetrics
- type Option
- type Outbox
- func (o *Outbox) Dedupe(ctx context.Context, tx postgres.Querier, consumer, key string) (bool, error)
- func (o *Outbox) Enqueue(ctx context.Context, tx postgres.Querier, msg Message) (string, error)
- func (o *Outbox) Migrate(ctx context.Context, db *postgres.DB) error
- func (o *Outbox) NewRelay(db *postgres.DB, pub Publisher, opts ...RelayOption) *Relay
- func (o *Outbox) Purge(ctx context.Context, q postgres.Querier, age time.Duration) (int64, error)
- func (o *Outbox) Redrive(ctx context.Context, q postgres.Querier, keys ...string) (int64, error)
- func (o *Outbox) SQL() ([]string, error)
- func (o *Outbox) Stats(ctx context.Context, q postgres.Querier) (Stats, error)
- func (o *Outbox) Table() string
- type Publisher
- type PublisherFunc
- type Record
- type Relay
- type RelayOption
- func WithBackoff(minDelay, maxDelay time.Duration) RelayOption
- func WithBatchSize(n int) RelayOption
- func WithConcurrency(n int) RelayOption
- func WithDrainTimeout(d time.Duration) RelayOption
- func WithLease(d time.Duration) RelayOption
- func WithMaxAttempts(n int) RelayOption
- func WithPollInterval(d time.Duration) RelayOption
- func WithoutNotify() RelayOption
- type Stats
Examples ¶
Constants ¶
const DefaultTable = "outbox"
DefaultTable is the outbox table name used when WithTable is not given.
Variables ¶
var ( // ErrInvalidTable is returned by New for a table name that is not a // plain, optionally schema-qualified, lowercase identifier. ErrInvalidTable = errors.New("outbox: invalid table name") // ErrInvalidMessage is returned by Enqueue and Dedupe for input that is // missing a required field. ErrInvalidMessage = errors.New("outbox: invalid message") // ErrDuplicateKey is returned by Enqueue when the idempotency key is // already in the outbox. The caller's transaction is still usable. ErrDuplicateKey = errors.New("outbox: duplicate idempotency key") )
Functions ¶
func IsPermanent ¶
IsPermanent reports whether err, or any error it wraps, was marked with Permanent.
func Permanent ¶
Permanent marks err as not worth retrying. A Publisher returns it for a message the broker will never accept (a malformed payload, a rejected destination), and the relay dead-letters the row at once instead of spending its remaining attempts. Permanent(nil) is nil.
Example ¶
package main
import (
"context"
"errors"
outbox "github.com/Bugs5382/go-outbox"
)
func main() {
pub := outbox.PublisherFunc(func(ctx context.Context, r outbox.Record) error {
if len(r.Payload) == 0 {
return outbox.Permanent(errors.New("empty payload"))
}
return nil
})
_ = pub
}
Output:
Types ¶
type Message ¶
type Message struct {
// Key is the idempotency key. It is unique per outbox table, travels with
// every delivery attempt, and is what consumers deduplicate on. Leave it
// empty to have a random UUID generated.
Key string
// Topic says where the message goes; the RabbitMQ publisher uses it as
// the routing key. Required.
Topic string
// AggregateKey scopes ordering: messages that share one are published
// one at a time, in enqueue order. Empty means no ordering constraint.
AggregateKey string
// Payload is the message body, stored as bytes.
Payload []byte
// ContentType describes Payload, such as application/json.
ContentType string
// Headers are carried to the publisher unchanged.
Headers map[string]string
// Delay holds the message back for this long after the enqueue.
Delay time.Duration
}
Message is one event to publish once the transaction that enqueues it commits.
type Metrics ¶
type Metrics interface {
Claimed(n int)
Published(topic string, attempt int, took time.Duration)
Failed(topic string, attempt int, err error)
DeadLettered(topic string, attempts int)
Released(n int)
}
Metrics receives relay events.
type NopMetrics ¶
type NopMetrics struct{}
NopMetrics discards every event.
func (NopMetrics) Claimed ¶
func (NopMetrics) Claimed(int)
func (NopMetrics) DeadLettered ¶
func (NopMetrics) DeadLettered(string, int)
func (NopMetrics) Released ¶
func (NopMetrics) Released(int)
type Option ¶
type Option func(*config)
Option configures an Outbox.
func WithLogger ¶
WithLogger sets the go-log logger the outbox and its relays write to. The default discards everything.
func WithMetrics ¶
WithMetrics sets the hook that receives relay events. The default discards them.
func WithTable ¶
WithTable sets the outbox table name, optionally schema-qualified ("audit.events"). Each part is lowercase letters, digits and underscores, starts with a letter or underscore, and is at most 50 characters. The inbox and migration tables are named after it, with _inbox and _migrations suffixes, and the notification channel is the table name.
type Outbox ¶
type Outbox struct {
// contains filtered or unexported fields
}
Outbox writes messages to an outbox table, inside the caller's transaction, and builds relays that drain that table. An Outbox holds no connection and is safe for concurrent use.
func New ¶
New builds an Outbox. It returns ErrInvalidTable when the table name is not a plain, optionally schema-qualified, lowercase identifier.
func (*Outbox) Dedupe ¶
func (o *Outbox) Dedupe(ctx context.Context, tx postgres.Querier, consumer, key string) (bool, error)
Dedupe is the consumer half of exactly-once processing on top of at-least-once delivery. It records that consumer has handled the message with key and reports whether this is the first time. Call it in the same transaction as the consumer's own writes: when it returns false the message was already handled and can be acknowledged without doing anything; when the transaction rolls back, the record goes with it and a redelivery is fresh again. Each consumer name keeps its own record of keys.
Example ¶
package main
import (
"context"
outbox "github.com/Bugs5382/go-outbox"
postgres "github.com/Bugs5382/go-postgres"
rabbitmq "github.com/Bugs5382/go-rabbitmq"
"github.com/jackc/pgx/v5"
)
func main() {
ctx := context.Background()
db, err := postgres.New(ctx, "postgres://app:secret@localhost:5432/app")
if err != nil {
return
}
defer db.Close()
ob, _ := outbox.New()
handle := func(ctx context.Context, d rabbitmq.Delivery) error {
return db.RunInTx(ctx, func(tx pgx.Tx) error {
fresh, err := ob.Dedupe(ctx, tx, "invoicing", d.MessageID)
if err != nil || !fresh {
return err
}
_, err = tx.Exec(ctx, `INSERT INTO invoices (order_id) VALUES ($1)`, string(d.Body))
return err
})
}
_ = handle
}
Output:
func (*Outbox) Enqueue ¶
Enqueue writes msg to the outbox through tx and returns its idempotency key. Pass the transaction that makes the change the message announces (from postgres.DB.RunInTx or RunInTxQuerier): the row, and the wake-up notification for idle relays, then exist only if that transaction commits.
A key that is already in the table returns ErrDuplicateKey without aborting tx, so the caller can treat a replayed request as done. A message without a Topic returns ErrInvalidMessage.
func (*Outbox) Migrate ¶
Migrate creates the outbox and inbox tables, or brings them up to date. It is safe to run on every start and from several processes at once: the steps run in one transaction under an advisory lock, and each applied version is recorded in the <table>_migrations table, so a step never runs twice. A schema-qualified table needs its schema to exist already.
To run the schema through a migration tool of your own instead, take the statements from SQL.
func (*Outbox) NewRelay ¶
NewRelay builds a relay that drains this outbox through db and sends through pub. It does nothing until Run or RunOnce.
func (*Outbox) Purge ¶
Purge deletes sent rows older than age and reports how many it removed. Pending and dead rows are never purged. Run it on a schedule; the relay does not delete anything by itself.
func (*Outbox) Redrive ¶
Redrive returns dead letters to pending with a fresh attempt count, so a relay publishes them again. With no keys it redrives every dead letter. It reports how many rows it changed.
func (*Outbox) SQL ¶
SQL returns the schema statements Migrate runs, rendered for this outbox's table names, one string per migration in version order. Use it to feed the schema to your own migration tool instead of calling Migrate.
type PublisherFunc ¶
PublisherFunc adapts a function to Publisher.
type Relay ¶
type Relay struct {
// contains filtered or unexported fields
}
Relay moves committed messages from the outbox to a Publisher. Each cycle it claims a batch of due rows under a lease (FOR UPDATE SKIP LOCKED, so any number of relays can run against one table without claiming the same row), publishes them, and marks each one sent, scheduled for a retry, or dead.
Delivery is at least once: a relay that stops between publishing a row and marking it leaves the row leased, and once the lease runs out another relay publishes it again with the same idempotency key.
func (*Relay) Listening ¶
Listening reports whether the relay currently holds its LISTEN subscription. It is false with WithoutNotify, before Run starts, and while the notification connection is being re-established.
func (*Relay) Run ¶
Run drains the outbox until ctx ends, then shuts down gracefully: it stops claiming, lets publishes already under way finish (up to the drain timeout) and records their outcome, and releases claimed rows it had not started so another relay can take them at once. It returns nil after a shutdown. Errors along the way are logged and retried, never returned.
func (*Relay) RunOnce ¶
RunOnce runs a single cycle: it claims up to one batch of due rows, publishes them and records each outcome. It returns how many rows it claimed. Run calls it in a loop; call it yourself to drive the relay from a scheduler or a test. When ctx ends part way, rows not yet started are released and rows in flight finish within the drain timeout.
type RelayOption ¶
type RelayOption func(*relayConfig)
RelayOption configures a Relay.
func WithBackoff ¶
func WithBackoff(minDelay, maxDelay time.Duration) RelayOption
WithBackoff sets the retry delay range (default 1s to 5m). The delay doubles per attempt from minDelay, is capped at maxDelay, and is jittered into the upper half of that value.
func WithBatchSize ¶
func WithBatchSize(n int) RelayOption
WithBatchSize sets how many rows one cycle claims (default 100).
func WithConcurrency ¶
func WithConcurrency(n int) RelayOption
WithConcurrency sets how many claimed rows are published at the same time (default 4). Rows of one aggregate are never in flight together, whatever the setting.
func WithDrainTimeout ¶
func WithDrainTimeout(d time.Duration) RelayOption
WithDrainTimeout bounds how long Run waits, after its context ends, for in-flight publishes to finish (default 10s).
func WithLease ¶
func WithLease(d time.Duration) RelayOption
WithLease sets how long a claim holds its rows (default 30s). It must comfortably exceed the time to publish one batch: a lease that runs out mid-publish lets another relay send the same rows again. After a crash, the lease is also how long the crashed relay's rows wait to be reclaimed.
func WithMaxAttempts ¶
func WithMaxAttempts(n int) RelayOption
WithMaxAttempts sets how many publish attempts a row gets before it is dead-lettered (default 10).
func WithPollInterval ¶
func WithPollInterval(d time.Duration) RelayOption
WithPollInterval sets how often an idle relay looks for due rows (default 2s). Notifications wake it sooner; polling is what finds retries whose backoff has passed, delayed messages, expired leases, and rows committed while the notification connection was down.
func WithoutNotify ¶
func WithoutNotify() RelayOption
WithoutNotify turns off the LISTEN/NOTIFY wake-up, leaving polling only. Use it behind a connection pooler in transaction mode, which cannot hold a LISTEN.
type Stats ¶
type Stats struct {
// Pending rows wait to be published, including rows backing off after
// a failed attempt. Rows a relay holds right now are in InFlight instead.
Pending int64
// InFlight rows are claimed by a relay under an unexpired lease.
InFlight int64
// Sent rows were published and are kept until Purge removes them.
Sent int64
// Dead rows ran out of attempts or failed permanently.
Dead int64
// OldestPending is the age of the oldest pending or in-flight row, the
// relay's lag. Zero when nothing is waiting.
OldestPending time.Duration
}
Stats counts the outbox rows by state.