Skip to content
chiltepin

Generated from: “We lose events when the database commit succeeds but the publish to the broker fails. Document the fix we agreed on.

Outbox for order events

Written by an agent from the skill, validated by chiltepin check, rendered by the renderer — shown as generated, 13 September 2026.

DOCUMENTDECIDED

Outbox for order events

Why a committed order can lose its event today, and the transactional outbox that stops it.

The Orders API writes the order row, commits, and then publishes order.placed to Kafka. When the broker is down or the process dies between those two steps, the order exists and the event does not. Billing and Notifications never hear about it, and nothing retries. The fix moves the event into the same Postgres transaction as the order, and a separate relay carries it to the broker.

SECTION 01 · Note

Assumptions

Note
One Postgres database per service, Kafka as the broker, and one Outbox Relay process per service with SKIP LOCKED for safe scale-out. Consumers already key on order_id. The change is scoped to the Orders API; other services adopt the same table when they next touch their publish path.

One transaction, then a relay

The order row and the outbox row commit together or not at all. The API returns 201 as soon as the commit lands, and never talks to Kafka. The Outbox Relay polls outbox every 200 ms, publishes each PENDING row, and marks it PUBLISHED only after the broker acknowledges the write. A broker outage leaves rows PENDING; it cannot lose them.

SECTION 02 · Sequence
SEQUENCE
Sequence diagram: 16 messages between 6 actorsClientOrders APIPostgresOutbox RelayKafkaBillingLOOP[every 200 ms]ALT[broker acknowledges][broker down or timeout after 5 s]1POST /orders2BEGIN3INSERT orders (status PAID)4INSERT outbox (order.placed, PENDING)5COMMIT6committed7201 Created8SELECT outbox WHERE status = PENDING FOR UPDATE SKIP LOCKED9up to 100 rows10publish order.placed (key = order_id)11ack (partition, offset)12UPDATE outbox SET status = PUBLISHED, published_at = now()13order.placed14skip if event_id already in processed_events15no ack16UPDATE outbox SET attempts = attempts + 1, next_attempt_at = backoff
Legendcallresponseerrorthe answer the caller getsfragment (alt / opt / loop)active

Outbox row lifecycle

A row is born PENDING inside the order transaction and leaves that state only on a broker acknowledgement. Ten failed attempts move it to FAILED, which pages the on-call engineer; a requeue puts it back to PENDING with attempts reset to zero. Published rows stay seven days for audit and replay, then a nightly job deletes them.

SECTION 03 · State machine
STATE
State machine: 5 states, 5 transitionsPENDINGPUBLISHEDFAILED12345
Legendstartstatewaitingtransitionsuccess exit
FromEventGuardTo
1s0INSERT in the order transactionPENDING
2PENDINGbroker ackPUBLISHED
3PENDING10 attempts without ackFAILED
4FAILEDoperator requeuesPENDING
5PUBLISHED7 days after published_atPURGED

Guarantees and numbers

The pattern trades exactly-once for at-least-once plus idempotent consumers. A relay that crashes after the broker ack but before the UPDATE republishes the same row, so every consumer must treat event_id as the dedupe key. These values are the contract; change them in config and in this table together.

SECTION 04 · Spec

Outbox contract

Atomicity
The orders row and the outbox row commit in one Postgres transaction. No code path commits one without the other.
Delivery
At-least-once. A row is PUBLISHED only after the broker ack; a crash between ack and UPDATE republishes it.
Idempotency
Consumers store event_id in processed_events for 7 days and skip a duplicate before any side effect.
Ordering
Partition key is order_id. The relay publishes rows in id order, so events for one order stay in sequence.
Retry
Attempt 1 at onceBackoff 1 s, 2 s, 4 s ... capped at 60 sFAILED after attempt 10Page on-call
Poll
Every 200 ms, batch of 100 rows, SELECT ... FOR UPDATE SKIP LOCKED so two relay instances never take the same row.
Lag target
p99 commit-to-ack under 2 s. Alert when the oldest PENDING row is older than 60 s.
Retention
PUBLISHED rows are deleted 7 days after published_at. FAILED rows are kept until an operator requeues or deletes them.

What each fault does now

Every fault that lost events before now either delays the event or produces a duplicate that the consumer drops. No fault deletes a committed event.

SECTION 05 · Comparison
FaultBefore the outboxWith the outbox
Broker down when the order commitsEvent lost; order exists with no downstream workRow stays PENDING; relay retries with backoff until the broker returns
API process dies after COMMITEvent lostRow is already committed; relay publishes it on the next poll
Relay dies after ack, before UPDATENot applicableRow is republished; consumer drops the duplicate by event_id
Two relay instances poll at onceNot applicableSKIP LOCKED gives each row to one instance
Postgres down during POST /orders503 to the client, nothing written503 to the client, nothing written; no event to lose
Payload the broker rejects every timeSilent loss after one attemptFAILED after 10 attempts; on-call is paged with the row id

Attempt count, backoff cap, and retention are the production values on the day of this decision.

View the Markdown
```meta
title: Outbox for order events
subtitle: Why a committed order can lose its event today, and the transactional outbox that stops it.
tag: DECIDED
```

The Orders API writes the order row, commits, and then publishes `order.placed`
to Kafka. When the broker is down or the process dies between those two
steps, the order exists and the event does not. Billing and Notifications
never hear about it, and nothing retries. The fix moves the event into the
same Postgres transaction as the order, and a separate relay carries it to
the broker.

```callout
tone: note
title: Assumptions
body: "One Postgres database per service, Kafka as the broker, and one Outbox Relay process per service with SKIP LOCKED for safe scale-out. Consumers already key on order_id. The change is scoped to the Orders API; other services adopt the same table when they next touch their publish path."
```

## One transaction, then a relay

The order row and the outbox row commit together or not at all. The API
returns `201` as soon as the commit lands, and never talks to Kafka. The
Outbox Relay polls `outbox` every 200 ms, publishes each `PENDING` row, and
marks it `PUBLISHED` only after the broker acknowledges the write. A broker
outage leaves rows `PENDING`; it cannot lose them.

```sequence
id: outbox-sequence
actors:
  - { id: Client, name: Client }
  - { id: OrdersAPI, name: Orders API }
  - { id: Postgres, name: Postgres }
  - { id: Relay, name: Outbox Relay }
  - { id: Kafka, name: Kafka }
  - { id: Billing, name: Billing }
messages:
  - Client -> +OrdersAPI: POST /orders
  - OrdersAPI -> +Postgres: BEGIN
  - OrdersAPI -> Postgres: INSERT orders (status PAID)
  - OrdersAPI -> Postgres: INSERT outbox (order.placed, PENDING)
  - OrdersAPI -> Postgres: COMMIT
  - Postgres --> -OrdersAPI: committed
  - OrdersAPI --> -Client: 201 Created
  - loop: every 200 ms
  - Relay -> +Postgres: SELECT outbox WHERE status = PENDING FOR UPDATE SKIP LOCKED
  - Postgres --> -Relay: up to 100 rows
  - Relay --> +Kafka: publish order.placed (key = order_id)
  - alt: broker acknowledges
  - Kafka --> -Relay: ack (partition, offset)
  - Relay -> Postgres: UPDATE outbox SET status = PUBLISHED, published_at = now()
  - Kafka --> Billing: order.placed
  - Billing -> Billing: skip if event_id already in processed_events
  - else: broker down or timeout after 5 s
  - Kafka -x-> -Relay: no ack
  - Relay -> Postgres: UPDATE outbox SET attempts = attempts + 1, next_attempt_at = backoff
  - end
  - end
```

## Outbox row lifecycle

A row is born `PENDING` inside the order transaction and leaves that state
only on a broker acknowledgement. Ten failed attempts move it to `FAILED`,
which pages the on-call engineer; a requeue puts it back to `PENDING` with
`attempts` reset to zero. Published rows stay seven days for audit and
replay, then a nightly job deletes them.

```state
id: outbox-row-states
dir: LR
states:
  - { id: s0, col: 1, row: 1, kind: start }
  - { id: pending, col: 2, row: 1, kind: wait, name: PENDING }
  - { id: published, col: 3, row: 1, kind: active, name: PUBLISHED }
  - { id: purged, col: 4, row: 1, kind: terminal, name: PURGED }
  - { id: failed, col: 2, row: 2, kind: wait, name: FAILED }
transitions:
  - s0 -> pending: INSERT in the order transaction
  - pending -> published: broker ack
  - pending -> failed: 10 attempts without ack
  - failed -> pending: operator requeues
  - published -> purged: 7 days after published_at
```

## Guarantees and numbers

The pattern trades exactly-once for at-least-once plus idempotent consumers.
A relay that crashes after the broker ack but before the `UPDATE` republishes
the same row, so every consumer must treat `event_id` as the dedupe key.
These values are the contract; change them in config and in this table
together.

```spec
id: outbox-invariants
title: Outbox contract
accent: teal
rows:
  - { label: Atomicity, value: "The orders row and the outbox row commit in one Postgres transaction. No code path commits one without the other." }
  - { label: Delivery, value: "At-least-once. A row is PUBLISHED only after the broker ack; a crash between ack and UPDATE republishes it." }
  - { label: Idempotency, value: "Consumers store event_id in processed_events for 7 days and skip a duplicate before any side effect." }
  - { label: Ordering, value: "Partition key is order_id. The relay publishes rows in id order, so events for one order stay in sequence." }
  - { label: Retry, steps: ["Attempt 1 at once", "Backoff 1 s, 2 s, 4 s ... capped at 60 s", "FAILED after attempt 10", "Page on-call"] }
  - { label: Poll, value: "Every 200 ms, batch of 100 rows, SELECT ... FOR UPDATE SKIP LOCKED so two relay instances never take the same row." }
  - { label: Lag target, value: "p99 commit-to-ack under 2 s. Alert when the oldest PENDING row is older than 60 s." }
  - { label: Retention, value: "PUBLISHED rows are deleted 7 days after published_at. FAILED rows are kept until an operator requeues or deletes them." }
```

## What each fault does now

Every fault that lost events before now either delays the event or produces
a duplicate that the consumer drops. No fault deletes a committed event.

```table
id: outbox-faults
columns: [Fault, Before the outbox, With the outbox]
rows:
  - ["Broker down when the order commits", { v: "Event lost; order exists with no downstream work", tone: neg }, { v: "Row stays PENDING; relay retries with backoff until the broker returns", tone: pos }]
  - ["API process dies after COMMIT", { v: "Event lost", tone: neg }, { v: "Row is already committed; relay publishes it on the next poll", tone: pos }]
  - ["Relay dies after ack, before UPDATE", "Not applicable", { v: "Row is republished; consumer drops the duplicate by event_id", tone: warn }]
  - ["Two relay instances poll at once", "Not applicable", { v: "SKIP LOCKED gives each row to one instance", tone: pos }]
  - ["Postgres down during POST /orders", "503 to the client, nothing written", { v: "503 to the client, nothing written; no event to lose", tone: muted }]
  - ["Payload the broker rejects every time", { v: "Silent loss after one attempt", tone: neg }, { v: "FAILED after 10 attempts; on-call is paged with the row id", tone: warn }]
note: "Attempt count, backoff cap, and retention are the production values on the day of this decision."
```