wip outbox library design

README.md

outpost #

Transactional outbox for Go. Durable messages, pluggable delivery, no driver types in the core.

Model #

Three seams: Appender (write side, bound to a unit of work the adapter owns), Store (relay side: Claim/Complete/Fail), and Producer (what the relay does with a claimed message). The contracts live in the package documentation; this file is the tour.

Handlers are written once and registered by name; any producer can feed them.

// request path: same unit of work as the business write
err := outpost.Emit(ctx, myStore.InTx(tx), "email.send",
    SendEmail{To: "ada@example.com", Template: "welcome"},
    outpost.Key("ada"))

// worker path
d := outpost.NewDispatcher()
outpost.Handle(d, "email.send", func(ctx context.Context, e SendEmail) error {
    info, _ := outpost.InfoFromContext(ctx)
    return mailer.Send(ctx, render(e), mailer.IdempotencyKey(info.ID))
})

relay, err := outpost.NewRelay(store, outpost.ProducerFunc(d.Dispatch))
if err != nil {
    return err
}
relay.Run(ctx)

Guarantees #

  • At-least-once delivery. Consumers must be idempotent; Message.ID is stable across retries and is the natural idempotency key.
  • Retries with configurable backoff, plus RetryAfter for provider rate limits and Permanent for failures that should not be retried. Both delays are floors: the relay spreads each retry by up to 50% so independent workers do not stampede a shared provider.
  • Messages are abandoned, not retried forever, after WithMaxAttempts.
  • The lease duration is derived from batch size, worker count, and delivery timeout: (ceil(batch/workers)+1) * deliveryTimeout. A claimed batch cannot outlive its lease while a worker is alive, so two workers cannot deliver the same message concurrently. The remaining duplicate window is a crash or hang between the provider accepting and Complete; no lease scheme closes that, which is what the provider idempotency key is for.
  • Stale leases cannot corrupt bookkeeping: Complete and Fail are ignored unless the claim's token still matches.

Non-goals #

  • Exactly-once delivery.
  • Ordering: a single relay with WithWorkers(1) processes claims in store order; nothing else is promised.
  • Codecs other than JSON.
  • Deferred acknowledgements (a delivery that completes later, for example a websocket client ack). The lease and token in Claim leave room for it.

Adapters #

Not written yet. An adapter provides:

  • something returning an Appender bound to its unit of work, for example InTx(tx), InBatch(batch), InTxn(txn);
  • a Store implementation (Claim, Complete, Fail) honoring the contract on the interface;
  • optionally a Producer for its broker.