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 ¶
- type InMemoryStore
- func (s *InMemoryStore) AttemptsOf(id string) int
- func (s *InMemoryStore) FetchUnpublished(limit int) ([]Record, error)
- func (s *InMemoryStore) LastErrorOf(id string) string
- func (s *InMemoryStore) MarkFailed(id, reason string) error
- func (s *InMemoryStore) MarkPublished(ids []string) error
- func (s *InMemoryStore) PendingCount() int
- func (s *InMemoryStore) Save(encoded []byte, queue string) (string, error)
- type Options
- type Outbox
- type Record
- type Relay
- type Result
- type Store
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).
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 ¶
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 ¶
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 ¶
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 ¶
Result summarizes a relay pass: how many pending rows were published and how many failed (and were left pending for a later retry).
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.