outbox

package
v1.6.1 Latest Latest
Warning

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

Go to latest
Published: Nov 13, 2026 License: MIT Imports: 8 Imported by: 0

Documentation

Overview

Package outbox implements the transactional-outbox pattern: events are written into an outbox table within the same database transaction as the business data, and a background Relay publishes pending events at-least-once.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func Enqueue

func Enqueue(ctx context.Context, db bun.IDB, topic string, payload any) error

Enqueue JSON-encodes payload and inserts a pending Event using db, which may be a *bun.DB or a bun.Tx. Pass your business transaction so the event commits atomically with your writes.

func Migrate

func Migrate(ctx context.Context, db *bun.DB) error

Migrate creates the outbox table (if it does not exist) with an index on (published_at, id) so pending-event scans stay cheap, and the outbox_dead table used by WithMaxAttempts.

func Prune added in v1.6.0

func Prune(ctx context.Context, db bun.IDB, olderThan time.Duration) (int64, error)

Prune deletes events published more than olderThan ago and returns how many were removed. Published rows are never deleted otherwise, so call it periodically (e.g. from a scheduled job) to keep the table small.

Types

type DeadEvent added in v1.6.0

type DeadEvent struct {
	bun.BaseModel `bun:"table:outbox_dead,alias:od"`

	ID        int64     `bun:"id,pk"`
	Topic     string    `bun:"topic,notnull"`
	Payload   []byte    `bun:"payload,notnull"`
	CreatedAt time.Time `bun:"created_at,notnull"`
	FailedAt  time.Time `bun:"failed_at,notnull"`
	Error     string    `bun:"error,notnull"`
}

DeadEvent is an event the relay gave up on (see WithMaxAttempts). It keeps the original ID, topic, payload, and creation time, plus when and why publishing was abandoned. Re-enqueue it with Enqueue to retry.

type Event

type Event struct {
	bun.BaseModel `bun:"table:outbox,alias:o"`

	ID          int64      `bun:"id,pk,autoincrement"`
	Topic       string     `bun:"topic,notnull"`
	Payload     []byte     `bun:"payload,notnull"`
	CreatedAt   time.Time  `bun:"created_at,notnull"`
	PublishedAt *time.Time `bun:"published_at"`
}

Event is one row of the outbox. Payload holds the JSON-encoded event body. PublishedAt is nil while the event is pending and set once the relay has published it.

type Publisher

type Publisher interface {
	Publish(ctx context.Context, topic string, payload []byte) error
}

Publisher publishes a single event's raw payload bytes to its topic.

func NewRedisPublisher

func NewRedisPublisher(client redis.UniversalClient) Publisher

NewRedisPublisher returns a Publisher that publishes the raw payload bytes on the topic channel via client.Publish, without re-encoding. The channel message is therefore exactly the stored JSON, so redis.Subscribe[T] on the consumer side decodes it directly.

type Relay

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

Relay reads pending events from the outbox and publishes them.

func NewRelay

func NewRelay(db *bun.DB, pub Publisher, opts ...RelayOption) *Relay

NewRelay returns a Relay that publishes outbox events from db via pub.

func (*Relay) Process

func (r *Relay) Process(ctx context.Context) (int, error)

Process publishes one batch of pending events, oldest first, up to the configured batch size. Each event's PublishedAt is set only after its Publish succeeds; a Publish error stops the batch and is returned, leaving the failed event and the rest pending (unless WithMaxAttempts dead-letters it, in which case the batch continues). It returns the number of events published. When a Locker is configured and another replica holds the lock, Process returns (0, nil). Process must not be called concurrently on the same Relay.

Example

Write an event in the same transaction as the business data, then let the relay publish it. If the transaction rolls back, no event is ever published.

package main

import (
	"context"
	"database/sql"
	"fmt"

	"github.com/uptrace/bun"
	"github.com/uptrace/bun/dialect/sqlitedialect"
	"github.com/uptrace/bun/driver/sqliteshim"

	"github.com/rahmadafandi/fibr/outbox"
)

// recordingPublisher prints each event it would publish.
type recordingPublisher struct{}

func (recordingPublisher) Publish(_ context.Context, topic string, payload []byte) error {
	fmt.Printf("publish %s %s\n", topic, payload)
	return nil
}

// Write an event in the same transaction as the business data, then let the
// relay publish it. If the transaction rolls back, no event is ever published.
func main() {
	sqldb, _ := sql.Open(sqliteshim.ShimName, "file::memory:?cache=shared")
	db := bun.NewDB(sqldb, sqlitedialect.New())
	defer db.Close()
	ctx := context.Background()
	_ = outbox.Migrate(ctx, db)

	// Business write + event, atomically.
	tx, _ := db.BeginTx(ctx, nil)
	_ = outbox.Enqueue(ctx, tx, "order.created", map[string]int{"order_id": 42})
	_ = tx.Commit()

	// Background relay publishes committed events at-least-once.
	relay := outbox.NewRelay(db, recordingPublisher{})
	n, _ := relay.Process(ctx)
	fmt.Println("published:", n)
}
Output:
publish order.created {"order_id":42}
published: 1

func (*Relay) Run

func (r *Relay) Run(ctx context.Context) error

Run calls Process every interval until ctx is cancelled, returning ctx.Err(). A Process error is logged and the loop continues, so a transient publish failure does not stop the relay.

type RelayOption

type RelayOption func(*Relay)

RelayOption configures a Relay.

func WithBatchSize

func WithBatchSize(n int) RelayOption

WithBatchSize sets the maximum number of events published per Process call. The default is 100.

func WithInterval

func WithInterval(d time.Duration) RelayOption

WithInterval sets how often Run calls Process. The default is one second.

func WithLock

func WithLock(l *lock.Locker, key string, ttl time.Duration) RelayOption

WithLock makes Process run each batch under a distributed lock, so only one replica relays at a time. The lock is held for ttl and renewed before every event, so ttl only needs to exceed the time to publish a single event.

func WithLogger added in v1.6.0

func WithLogger(l *logger.Logger) RelayOption

WithLogger sets the logger Run reports Process errors to. The default is logger.Default().

func WithMaxAttempts added in v1.6.0

func WithMaxAttempts(n int) RelayOption

WithMaxAttempts moves an event to the outbox_dead table (see DeadEvent) once its publish has failed n times in a row, so one poison event cannot block the queue forever. The default, 0, retries the head event indefinitely.

Jump to

Keyboard shortcuts

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