Using the Outbox Pattern for Reliable Billing Events
You reach for this the moment a billing event goes missing in production: an invoice is marked paid in Postgres, but the email service and the revenue-recognition pipeline never heard about it because the publish failed after the commit. This page is the concrete implementation companion to Outbox Pattern & Event Publishing — the parent covers the concepts and semantics; here we write the actual table, relay loop, and consumer for invoice.paid and payment.failed. Every example uses real billing identifiers and is safe to adapt directly.
Trade-offs
| Decision | Option A | Option B | When A wins |
|---|---|---|---|
| Relay claim | FOR UPDATE SKIP LOCKED |
Advisory lock + single worker | You want horizontal relay scaling |
event_id source |
Deterministic from domain key | Random gen_random_uuid() |
You need natural dedup on replay |
| Retention | Delete sent rows hourly |
Partition by month, drop old | Throughput is high; archival not needed |
| Poison handling | status='failed' + alert |
Infinite retry | You must not block the queue head |
| Ordering | Partition by subscription_id |
Global FIFO | Per-customer order is enough (it usually is) |
Deterministic event_ids are worth the small effort: deriving event_id from (invoice_id, 'paid') means a replay of the same business operation produces the same id, so the consumer dedups it for free.
Step-by-Step Implementation
1. Create the outbox table
CREATE TABLE event_outbox (
outbox_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL UNIQUE,
subscription_id UUID NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | sent | failed
attempts INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_at TIMESTAMPTZ
);
-- Relay scan touches only unsent rows
CREATE INDEX idx_outbox_pending ON event_outbox (created_at) WHERE status = 'pending';
2. Emit the event inside the billing transaction
The insert shares the transaction with the invoice update. Deriving event_id deterministically from invoice_id makes re-running the same operation idempotent end to end.
BEGIN;
UPDATE invoices
SET status = 'paid', paid_at = now()
WHERE invoice_id = $1
AND status = 'open'; -- guard against double-paying
INSERT INTO event_outbox (event_id, subscription_id, event_type, payload)
VALUES (
md5($1::text || ':paid')::uuid, -- deterministic event_id
$2, -- subscription_id
'invoice.paid',
jsonb_build_object(
'invoice_id', $1,
'subscription_id', $2,
'amount_cents', $3,
'currency', $4
)
)
ON CONFLICT (event_id) DO NOTHING; -- safe on replay
COMMIT; -- ✅ invoice paid AND event queued, atomically
3. Run the SKIP LOCKED relay loop
Multiple relay instances can run concurrently; SKIP LOCKED hands each worker a disjoint slice of pending rows with no coordination.
import time, json
POLL_INTERVAL = 0.2 # seconds
BATCH = 200
MAX_ATTEMPTS = 8
def relay_forever(db, broker):
while True:
published = relay_tick(db, broker)
if published == 0:
time.sleep(POLL_INTERVAL) # idle backoff when queue is empty
def relay_tick(db, broker) -> int:
with db.transaction() as txn:
rows = txn.fetch(f"""
SELECT outbox_id, event_id, subscription_id, event_type, payload, attempts
FROM event_outbox
WHERE status = 'pending'
ORDER BY created_at
LIMIT {BATCH}
FOR UPDATE SKIP LOCKED
""")
for r in rows:
try:
broker.publish(
topic=r["event_type"],
key=str(r["subscription_id"]), # per-subscription ordering
value=json.dumps(r["payload"]),
headers={"event_id": str(r["event_id"])},
)
txn.execute("UPDATE event_outbox SET status='sent', sent_at=now() "
"WHERE outbox_id=$1", r["outbox_id"]) # ✅ delivered
except BrokerError:
if r["attempts"] + 1 >= MAX_ATTEMPTS:
txn.execute("UPDATE event_outbox SET status='failed', "
"attempts=attempts+1 WHERE outbox_id=$1",
r["outbox_id"]) # ✗ poison
alert_operator(r["event_id"])
else:
txn.execute("UPDATE event_outbox SET attempts=attempts+1 "
"WHERE outbox_id=$1", r["outbox_id"]) # ⚠️ retry
return len(rows)
4. Deduplicate downstream
The consumer records event_id and applies its effect in one transaction. A payment.failed consumer, for example, advances dunning exactly once even if the relay republishes.
def on_payment_failed(event_id: str, payload: dict, db) -> None:
with db.transaction() as txn:
claimed = txn.execute(
"INSERT INTO consumed_events (event_id, consumed_at) "
"VALUES ($1, now()) ON CONFLICT DO NOTHING RETURNING event_id",
event_id)
if not claimed:
return # ✅ duplicate redelivery, already handled
advance_dunning(payload["subscription_id"], txn)
Verification & Testing
Assert atomicity first: roll back the billing transaction and confirm no outbox row exists. Assert at-least-once: crash the relay between publish and the mark-sent UPDATE, restart, and confirm the consumer’s effect ran exactly once thanks to dedup. Assert poison handling: force a row to fail MAX_ATTEMPTS times and confirm it lands in failed, not stuck at the head of the queue.
-- Backlog health: oldest unsent event age (alert if this climbs)
SELECT event_type,
count(*) AS pending,
now() - min(created_at) AS oldest_pending
FROM event_outbox
WHERE status = 'pending'
GROUP BY event_type
ORDER BY oldest_pending DESC;
-- Poison events awaiting operator action
SELECT event_id, event_type, attempts, created_at
FROM event_outbox
WHERE status = 'failed'
ORDER BY created_at;
Gotchas & Production Pitfalls
- Marking sent in a separate transaction reopens the dual-write gap. The
UPDATE ... status='sent'must be in the same transaction that claimed the row, or a crash leaves it locked-but-unmarked. Claim and mark together. - Random
event_ids defeat dedup on replay. If you re-emitinvoice.paidfrom a backfill with a fresh UUID, the consumer treats it as new. Derive the id from the domain key so replays collapse. - Forgetting retention turns the outbox into a slow table. Delivered rows accumulate forever. Run a retention job (
DELETE ... WHERE status='sent' AND sent_at < now() - interval '7 days') or partition by month. - Alerting on row count misses slow drains. A steady 10k pending rows can be healthy; a single event pending for ten minutes is not. Alert on oldest-pending age, not total count.
- Per-key ordering breaks if you forget the partition key. Publishing without
key=subscription_idlets the broker spread one subscription’s events across partitions, and consumers seeinvoice.paidbeforeinvoice.created.