outbox

package
v1.15.0 Latest Latest
Warning

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

Go to latest
Published: Oct 17, 2026 License: MIT Imports: 5 Imported by: 0

Documentation

Overview

Package outbox adds an optional, dependency-free transactional outbox to a babelqueue producer (ADR-0029): the message is persisted into the same database, in the same transaction, as the business data — so it commits or rolls back atomically with it — and a separate Relay publishes the durable rows afterwards. It is the Go mirror of the PHP BabelQueue\Outbox helper, and the producer-side counterpart to the consumer-side github.com/babelqueue/babelqueue-go/idempotency.

The pattern removes the producer dual write: a plain producer must both commit the business row (INSERT INTO orders …) and publish to the broker, two independent systems that disagree on a crash. The outbox makes the handoff to the broker a local problem (read a row, publish, mark it) instead of a cross-system one.

The transaction boundary is the CALLER'S. Outbox.Write is invoked from inside a transaction the caller already opened around its own business write; the caller commits both together. The core never opens, commits or rolls back anything — that is the whole point — which keeps it free of any DB driver (GR-7): the core defines the Store contract; a concrete adapter (a database table) binds it to a real connection. The reference InMemoryStore is for tests / single-process demos.

The stored value is the EnvelopeCodec-encoded wire envelope, byte-for-byte unchanged (GR-1): what the relay publishes is the same bytes that were stored, so trace_id is preserved end-to-end (GR-4) and cross-SDK parity holds (GR-5) — the relay never decodes, rebuilds or re-encodes the envelope. The outbox adds its own bookkeeping (id, status, attempts) around the envelope; it never adds a field to it.

Like idempotency and schema, it lives in the core module (zero dependencies, stdlib only) so it ships with every producer.

Usage — the caller owns the transaction boundary:

tx, _ := db.BeginTx(ctx, nil)
if err := insertOrder(ctx, tx, order); err != nil { // the business write
	tx.Rollback()
	return err
}
env, _ := babelqueue.Make("urn:babel:orders:created", data, babelqueue.WithQueue("orders"))
if _, err := ob.Write(env); err != nil { // same tx, via a tx-bound Store
	tx.Rollback()
	return err
}
return tx.Commit() // both, or neither

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type InMemoryStore

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

InMemoryStore is a process-local, goroutine-safe reference Store backed by a map — for tests and single-process demos. It has NO real transaction: Save just appends, so it cannot deliver the atomic-with-the-business-write guarantee a production store gives; use a database-backed adapter in production.

It still faithfully models the relay contract: rows are pending until MarkPublished, FetchUnpublished returns them oldest-first, and MarkFailed bumps the attempt count and stores the last reason while leaving the row pending for retry. It does not implement claim/locking (FetchUnpublished's documented adapter concern) — it is single-process.

func NewInMemoryStore

func NewInMemoryStore() *InMemoryStore

NewInMemoryStore returns an empty in-memory store.

func (*InMemoryStore) AttemptsOf

func (s *InMemoryStore) AttemptsOf(id string) int

AttemptsOf reports the recorded attempt count for one row (0 if unknown) — a test/inspection helper.

func (*InMemoryStore) FetchUnpublished

func (s *InMemoryStore) FetchUnpublished(limit int) ([]Record, error)

FetchUnpublished returns up to limit pending rows, oldest first. A non-positive limit returns no rows.

func (*InMemoryStore) LastErrorOf

func (s *InMemoryStore) LastErrorOf(id string) string

LastErrorOf reports the last recorded failure reason for one row ("" if none) — a test/inspection helper.

func (*InMemoryStore) MarkFailed

func (s *InMemoryStore) MarkFailed(id, reason string) error

MarkFailed bumps the attempt count and records the last reason for one row, leaving it pending; an unknown id is ignored.

func (*InMemoryStore) MarkPublished

func (s *InMemoryStore) MarkPublished(ids []string) error

MarkPublished marks the given rows published; unknown ids are ignored.

func (*InMemoryStore) PendingCount

func (s *InMemoryStore) PendingCount() int

PendingCount reports how many rows are still pending publish (test/inspection helper).

func (*InMemoryStore) Save

func (s *InMemoryStore) Save(encoded []byte, queue string) (string, error)

Save appends the encoded envelope as a new pending row and returns its id.

type Options

type Options struct {
	// BatchSize is how many rows to reserve and publish per [Relay.Flush] (default 100).
	BatchSize int
	// BackoffStep is the base backoff added per prior attempt (default 50ms).
	BackoffStep time.Duration
	// BackoffCap is the upper bound on a single backoff sleep (default 5s).
	BackoffCap time.Duration
	// Sleeper sleeps for d; defaults to time.Sleep. Inject a no-op (or recorder) in tests
	// so they stay instant.
	Sleeper func(d time.Duration)
}

Options configures a Relay. The zero value selects sensible defaults: a batch of 100, a 50ms backoff step, a 5s backoff cap, and time.Sleep as the sleeper.

type Outbox

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

Outbox is the write side of the transactional outbox: it turns a babelqueue envelope into a stored outbox row, so the message is persisted atomically with the business data and a separate Relay publishes it later.

It is intentionally tiny and dependency-free: Outbox.Write encodes via the frozen envelope codec (GR-1 — the bytes are stored unchanged; the outbox never adds an envelope field) and delegates persistence to the injected Store, which the caller binds to their own DB (GR-7). It does NOT begin or commit anything — the caller owns the transaction boundary, which is the whole point of the pattern.

func New

func New(store Store) *Outbox

New returns an Outbox that persists through store. The store is expected to be bound to the transaction the caller opens around each Outbox.Write (e.g. a tx-scoped adapter), so the message commits atomically with the business write.

func (*Outbox) Write

func (o *Outbox) Write(env babelqueue.Envelope) (id string, err error)

Write encodes env via the frozen codec (bytes unchanged) and persists it through the store, inside the transaction the caller has already opened. It captures the target queue from env's meta.queue (falling back to "default") so the relay can publish to the right queue without decoding the body, and returns the new outbox row id (for the caller's own correlation, if wanted).

It does not begin or commit a transaction. It returns the codec's error if the envelope is not cleanly encodable, or the store's error if persistence fails.

type Record

type Record struct {
	ID       string // the outbox row id (the store's primary key, not meta.id)
	Body     string // the frozen, encoded envelope JSON, byte-for-byte as stored
	Queue    string // the logical queue the relay should publish to
	Attempts int    // how many times the relay has already tried to publish this row
}

Record is one pending row read back from a Store for a Relay to publish. It pairs the store's bookkeeping (ID, Attempts) with the verbatim, frozen wire envelope (Body) and the queue it should go to. Body is the exact Encode output handed to Store.Save — the relay publishes these bytes unchanged (GR-1/GR-5), so trace_id is preserved end-to-end (GR-4) without the relay ever decoding the envelope.

type Relay

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

Relay is the read/publish side of the transactional outbox (ADR-0029): it drains the pending rows an Outbox committed and forwards each onto the broker through the frozen babelqueue babelqueue.Transport seam — the same publish-only contract every framework-less producer uses — marking every row published or failed.

Run it on a short interval (a worker loop, a scheduled command) after the business transaction commits. It only ever reads already-durable rows, so it never invents work.

Semantics — at-least-once handoff:

  • A row is marked published only AFTER babelqueue.Transport.Publish returns; if the process dies between publish and Store.MarkPublished, the row stays pending and is published again on the next pass. That is at-least-once: a downstream consumer must dedupe on the canonical meta.id (github.com/babelqueue/babelqueue-go/idempotency is that guard, the consumer-side mirror of this producer-side helper — ADR-0022).
  • A publish that errors is caught, Store.MarkFailed records the reason and bumps the attempt count, and the row stays pending for a later retry. One poison row never blocks the rest of the batch.
  • trace_id is preserved end-to-end (GR-4): the relay publishes the stored bytes verbatim — it never decodes, rebuilds or re-encodes the envelope — so the body that reaches the broker is byte-identical to what was stored (GR-1/GR-5).

Backoff: between a failed publish and continuing within the same pass, the relay sleeps for a bounded, linearly-growing delay (capped), to avoid hammering a broker that is briefly down. The sleeper is injectable so tests stay instant.

func NewRelay

func NewRelay(transport babelqueue.Transport, store Store, opts Options) *Relay

NewRelay returns a relay that drains store and publishes through transport, configured by opts (the zero Options selects defaults).

func (*Relay) Drain

func (r *Relay) Drain(ctx context.Context, maxPasses int) (Result, error)

Drain empties the outbox by repeatedly calling Relay.Flush while each pass keeps making progress (publishes at least one row), then returns the cumulative tally. The loop stops as soon as a pass publishes nothing — the outbox is empty, or only currently failing rows remain (those are left pending for a future Drain once the broker recovers). maxPasses is a hard safety ceiling so a degenerate store can never spin forever (a non-positive value uses a generous internal default).

func (*Relay) Flush

func (r *Relay) Flush(ctx context.Context) (Result, error)

Flush publishes one batch of pending rows. Each row the transport accepts is marked published; each that errors is marked failed (with a backoff before continuing) and left pending. It returns a per-pass tally. Call it repeatedly (a loop / cron) to drain the outbox; Relay.Drain loops until the outbox is empty.

The stored body rides verbatim: it is handed straight to babelqueue.Transport.Publish, never decoded or re-encoded.

type Result

type Result struct {
	Published int
	Failed    int
}

Result summarizes a relay pass: how many pending rows were published and how many failed (and were left pending for a later retry).

func (Result) Attempted

func (r Result) Attempted() int

Attempted is the total rows the relay touched (published + failed).

type Store

type Store interface {
	// Save persists one encoded envelope into the outbox, within the transaction the
	// caller has already opened around its business write, and returns the new row's
	// outbox id (the store's own primary key, NOT meta.id). The body is stored verbatim.
	Save(encoded []byte, queue string) (id string, err error)

	// FetchUnpublished reserves up to limit rows that are pending publish, oldest
	// first, so a relay can forward them. Implementations SHOULD lock/claim the rows
	// they return (e.g. SELECT … FOR UPDATE SKIP LOCKED, or a picked_at claim) so two
	// concurrent relays do not both publish the same row; at-least-once tolerates a rare
	// double send. The slice is empty when the outbox is drained.
	FetchUnpublished(limit int) ([]Record, error)

	// MarkPublished marks the given rows as successfully published (so they are never
	// relayed again). The relay calls it only after the transport accepted the message.
	MarkPublished(ids []string) error

	// MarkFailed records a failed publish attempt for one row: it increments the row's
	// attempt counter and stores the last reason, leaving the row pending so a later
	// relay pass retries it (at-least-once). A store MAY park a row that exceeds a
	// max-attempts threshold, but that policy is the adapter's, not the core's.
	MarkFailed(id, reason string) error
}

Store is the persistence seam for the transactional outbox (ADR-0029) — the durable "outbox" table an Outbox writer fills and a Relay drains. The core defines it and binds to NO database driver (GR-7); the caller binds a concrete adapter to their own connection, and the reference InMemoryStore backs tests.

Save runs inside the transaction the caller has already opened around its business write, so the message commits atomically with the business data. The body it stores is the frozen wire envelope, byte-for-byte unchanged — implementations must not re-encode or mutate it.

Jump to

Keyboard shortcuts

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