import "github.com/altessa-s/go-atlas/data/outbox"Package outbox implements the Transactional Outbox pattern for at-least-once event delivery. Events are persisted to a Storage before being
dispatched via a Handler, with background cycles for dispatch, retry, expiration, cleanup, stuck-event recovery, and backlog measurement.
Transport-agnostic: the Handler callback determines delivery method (message broker, HTTP, gRPC, etc.).
| Type / Interface | Description |
|---|---|
Outbox |
Main outbox manager with background workers |
Event |
Stored event with delivery tracking |
Status |
Lifecycle — see the status table below |
Handler |
Callback that dispatches events |
Storage |
Persistence interface for event storage |
Watcher |
Optional Storage capability: push notifications on save |
Stats |
Backlog snapshot: queue depth, dead-letter depth, lag |
| Status | Terminal | Meaning |
|---|---|---|
pending |
no | Awaiting first dispatch |
in-progress |
no | Locked by a dispatcher |
failed |
no | Attempt failed; retried once the backoff elapses |
sent |
yes | Dispatched successfully |
skipped |
yes | Superseded by a newer event with the same key (compaction) |
expired |
yes | Passed its ExpiresAt before being dispatched |
max-attempt-reached |
yes | Exhausted the retry budget — dead-lettered |
rejected |
yes | Classified as permanently undeliverable — dead-lettered |
max-attempt-reached and rejected are the dead-letter queue: they are excluded from cleanup and stay until an operator acts on them.
| Option | Default | Description |
|---|---|---|
WithFetchTimeout |
5s | Store fetch timeout |
WithHandleTimeout |
20s | Per-cycle dispatch timeout |
WithUpdateTimeout |
5s | Store update timeout |
WithMaxLockTime |
40s | Lock lifetime before the unlock cycle reclaims an event |
WithEventsBatchSize |
200 | Events fetched per batch |
WithRetryMaxAttempts |
10 | Maximum dispatch attempts before dead-lettering |
WithRetryBaseDelay |
1s | Backoff before the second attempt |
WithRetryMaxDelay |
5m | Backoff ceiling |
WithMaxPayloadBytes |
1 MiB | Payload size limit; 0 disables the check |
WithPublishedEventsLifetime |
indefinite | Retention for processed events |
WithDefaultEventTTL |
off | Default ExpiresAt applied at save time |
WithCompaction |
off | Deduplicate events by key within a batch |
WithCompactionFilter |
nil | Selective key-based compaction |
WithShouldRetry |
nil | Transient/permanent error predicate |
WithCollector |
no-op | Metrics collector |
WithLogger |
discard | Structured logger |
WithScheduler |
nil | Task registrar that runs the cycles |
With*Schedule |
-- | Cron schedule per cycle (Dispatch, Unlock, Expire, Cleanup, Stats); empty leaves the cycle unscheduled |
With*TaskID |
outbox-<cycle> |
Scheduler task ID per cycle; must be distinct across outboxes sharing a scheduler |
maxLockTime must exceed handleTimeout; a smaller value is raised at construction with a warning. A lock that expires while its dispatch cycle is
still publishing lets the unlock cycle hand the event to a second worker, which makes duplicate delivery the steady state rather than an edge case.
Save only closes the dual-write gap if it runs in the same transaction as the business data:
_, err := sess.WithTransaction(ctx, func(sessCtx context.Context) (any, error) {
if err := repo.CreateOrder(sessCtx, order); err != nil {
return nil, err
}
return nil, ob.Save(sessCtx, outbox.Event{Key: "billing.order.created", Payload: payload})
})The whole batch is validated before anything is written, so a rejected Save (ErrEmptyKey, ErrPayloadTooLarge) leaves the caller's transaction
clean and abortable.
At-least-once. Duplicates are expected — a handler can publish successfully and then fail before its status is written back — so consumers must be
idempotent. Where the transport supports deduplication, key it off Event.Id: it is assigned once at save time and never changes across retries. The
transport/broker/outbox adapter does this automatically.
Ordering is not preserved. Batches are fetched oldest-first but dispatched concurrently, and a failed event is rescheduled behind events created
after it. Do not assume ordering, even within a single Key.
Each cycle spends exactly one attempt per event; spacing comes from an exponential backoff with jitter that the store anchors to its own clock. Errors
are classified by WithShouldRetry: transient ones are rescheduled, permanent ones (a malformed payload, an unrejectable subject) go straight to
rejected rather than burning the whole budget first. Context cancellation and timeouts always count as transient, so a broker outage never rejects
healthy events outright; an outage that outlasts the attempt budget still ends in max-attempt-reached.
Schedule the stats cycle (WithStatsSchedule) and alert on the gauges it publishes — outbox_events_pending, outbox_events_dead_lettered, and
outbox_events_oldest_pending_age_seconds. Counters alone cannot distinguish a stalled outbox from an idle one: both report zero. See
docs/metrics.md, which also lists the metrics renamed in this change.
| Cycle | Method | Purpose |
|---|---|---|
| dispatch | RunDispatchCycle |
Fetch a batch, dispatch it, write back the outcome |
| unlock | RunUnlockCycle |
Reclaim events whose lock outlived maxLockTime |
| expire | RunExpireCycle |
Mark events past ExpiresAt as expired |
| cleanup | RunCleanupCycle |
Delete processed events older than the retention window |
| stats | RunStatsCycle |
Refresh the backlog, dead-letter, and lag gauges |
Configure cycles with WithScheduler and the matching With*Schedule, then call RegisterTasks(ctx). The expire cycle is registered only with a
positive WithDefaultEventTTL, and the cleanup cycle only with a positive WithPublishedEventsLifetime. After successful registration the manual
Run* method returns corescheduler.ErrSchedulerManaged. Task IDs are overridable and must be distinct — collisions are rejected by RegisterTasks
with ErrTaskIDCollision.
A saved event otherwise waits up to one dispatch interval. When the store implements the optional Watcher interface, Outbox.Watch blocks on its
notifications and runs a dispatch cycle as soon as events land — the MongoDB store implements it with a change stream over the outbox collection, so an
event becomes visible the instant its transaction commits:
go func() {
switch err := ob.Watch(ctx); {
case err == nil, errors.Is(err, outbox.ErrWatchUnsupported):
// Nothing to do: dispatch continues on the scheduled poll cycle.
default:
logger.Error("outbox watcher stopped", slog.Any("error", err))
}
}()Keep the dispatch schedule configured. A notification means "something arrived", never "this event is due": a retry backoff elapsing and the unlock
cycle freeing a stuck lease are time-driven and produce no notification at all, and a notification arriving while a cycle is already running is dropped
rather than queued. Watch shortens the common case; the poll cycle remains the guarantee. A cycle that comes back with a full batch re-arms itself, so
a bulk insert drains without waiting for the next tick.
ErrWatchUnsupported is a capability report, not a failure — it is what a store without the interface, or a standalone mongod with no oplog, returns.
outbox_watch_notifications_total going flat while outbox_events_saved_total climbs means the watcher died and dispatch silently fell back to the
poll interval.
The retry, lock-expiry, and retention windows passed to the Storage are durations, not absolute timestamps: Event.RetryAfter,
UnlockStuckEvents(lockExpiry), DeleteProcessedEvents(olderThan), and ExpireEvents. The store evaluates them against its own database server clock,
so a worker whose wall clock is skewed cannot prematurely unlock another worker's in-flight event or leak a stuck one. Writes are additionally fenced by
Event.LockToken, so a dispatcher that lost its lease cannot overwrite the result of the worker that took over. See storages/mongo
for the MongoDB $$NOW implementation and the one-time BSON Date migration, and storages/sqldb for the SQL now() /
UTC_TIMESTAMP(6) implementation.
| Package | Description |
|---|---|
| factory | Configuration-based creation |
| storages/mongo | MongoDB-backed event storage |
| storages/sqldb | PostgreSQL / MySQL / MariaDB storage through database/sql |
New does not register schedules. Call RegisterTasks(ctx) explicitly and handle errors before serving traffic. If only some tasks were registered,
their callbacks return ErrRegistrationIncomplete until registration completes. Retry on the same outbox: successful registrations are retained and
only missing tasks are retried. Unregistered cycles remain callable manually. Repeated successful registration is a no-op.
Factories perform registration automatically. On a registration error they return both the outbox and the error, so the caller can retain that instance
and retry RegisterTasks(ctx). Treat this as a failed startup until the retry succeeds.