Skip to content

The transactional outbox

Writing a run result and publishing its event are two operations against two systems. Either can succeed alone. The two failures look like this:

  • the state committed and the publish failed — a completed run nothing downstream hears about;
  • the publish succeeded and the transaction rolled back — a completion that never happened, acted on by every consumer that was listening.

Neither raises anything at the time. They surface weeks later as a count nobody can explain.

The outbox removes the second operation. The event is inserted into a table in the same database, inside the caller's transaction, so it commits with the state or disappears with it. A relay moves committed rows onto the real transport afterwards.

flowchart LR
    Caller[caller's transaction] --> State[state row]
    Caller --> Row[outbox row]
    Row -.commits or rolls back together.-> State
    Relay[relay] -- claim a run --> Row
    Relay -- publish --> Transport[JetStream]
    Transport -- ack --> Mark[mark published]
    Relay -- undecodable or oversized --> Letters[dead letter]

Using it

from tesserix_adk.adapters import OutboxRelay, PostgresOutbox, PostgresOutboxSettings

outbox = await PostgresOutbox.open(session, clock=clock, settings=PostgresOutboxSettings(dsn=dsn))

async with transactor.transaction() as tx:
    await states.bound(tx).put_run(record)
    await outbox.bound(tx).publish(event)

The relay is a separate process, or a task in the same one:

relay = OutboxRelay(transactor, jetstream, clock=clock, worker=hostname, dead_letter=letters)

while running:
    if await relay.deliver() == 0:
        await clock.sleep(poll_seconds)

Eventing takes the outbox anywhere an EventPublisher goes, so nothing above the adapter layer knows which one it is.

What is guaranteed

Atomicity. The event is exactly as reliable as the transaction that caused it. A rollback publishes nothing, because there is nothing to publish.

At-least-once, not at-most-once. The relay publishes before it marks the row, inside the claiming transaction. A crash in between republishes on the next poll. That duplicate is suppressed by publish-side dedupe on the event id — JetStreamEventPublisher sets Nats-Msg-Id, and the stream collapses the second copy within its dedupe window. Marking first would lose the event instead, which is the trade nobody wants.

Order per run. Rows are claimed a whole run at a time under pg_try_advisory_xact_lock(hashtext(run_id)), held for the claiming transaction. Two relay replicas therefore never hold events of the same run at once, and its events cannot overtake each other.

What is not

There is no global order. Events of different runs may reach the transport in any order, and no amount of locking fixes that without serialising every relay in the fleet. Consumers that need a total order need a sequence number they define themselves.

The transport call happens while the advisory lock is held. That is the cost of claiming and marking in one transaction. A slow transport holds a lock on one run, not on the table — but keep the relay's batch small enough that a publish timeout cannot outlive the connection's statement_timeout.

There is no Redis outbox. The pattern needs a store that can commit the state change and the row together. Redis cannot, and an outbox that is not in the same transaction is just two operations with more moving parts. Use PostgreSQL, or accept at-most-once.

The relay does not create the table. EXPECTED_OUTBOX_SCHEMA is the DDL the adapter was written against; the platform's migration repository owns applying it. open() reads the recorded version and refuses a database that is a different shape, at startup rather than at the first write.

Failure handling

A transport that is down for ten minutes loses nothing: deliver raises, the transaction rolls back, no row is marked, and the rows accumulate. Delivery resumes in per-run order on recovery. relay.failed counts the polls that raised.

Two rows can never be delivered and would otherwise sit at the head of their run for ever:

Reason What it is
undecodable The payload is not an EventEnvelope — a hand-written row, or a schema that moved under it.
too_large Bigger than the transport will carry. Retrying it is a loop, not a recovery.

Both go to the dead_letter if one is configured, are counted in relay.buried, and are marked published so they stop blocking the run behind them.

Operating it

lag() returns the two numbers worth alerting on:

Metric Alert when
unpublished Above the batch size for more than a few poll intervals — the relay is behind or dead.
oldest_seconds Above 60s. A row older than that means delivery has stopped, not slowed.

A sustained outage grows the table until the disk fills, so page on oldest_seconds rather than waiting for unpublished to look alarming.

prune() removes published rows older than retention_seconds (a week by default) and never touches an unpublished one. Run it on a schedule; the partial index on published_at keeps it off the relay's own read path.

Redaction

The envelope is scrubbed where it is built — see events — so the outbox stores what the transport would have carried and nothing more. Nothing redacts at insert time, because a row that was written unredacted has already been persisted.