Transactional Outbox for Cross-Shard Event Publishing
When a write on one shard must cause an effect somewhere else — another shard, a search index, a webhook — the outbox pattern makes that effect inevitable without a distributed transaction. This guide builds one: the table, the relay, ordering guarantees, and the retention that stops it becoming the largest table in the schema. It implements the durability-of-intent mechanism from Distributed Transactions & Cross-Shard Consistency, part of Cross-Partition Querying & Aggregation Strategies.
Prerequisites
Step 1 — Write the event in the same transaction as the data
This single property is the whole pattern:
CREATE TABLE outbox (
id bigint GENERATED ALWAYS AS IDENTITY,
aggregate_type text NOT NULL,
aggregate_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
delivered_at timestamptz,
attempts int NOT NULL DEFAULT 0,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE INDEX outbox_undelivered_idx
ON outbox (aggregate_id, id) WHERE delivered_at IS NULL;
BEGIN;
UPDATE accounts SET balance = balance - 2500 WHERE id = 8842;
INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES ('account', '8842', 'funds_reserved',
jsonb_build_object('amount', 2500, 'dst_account', 1190,
'idempotency_key', '9f1c2d64-0a1e-4f7c-9d2b-3d8e5c1a77f0'));
COMMIT;
If the transaction commits, the event exists. If it rolls back, neither the balance change nor the event happened. There is no third state, which is exactly what a publish-after-commit cannot promise.
Operational note: The partial index on undelivered rows keeps the relay’s claim query fast no matter how many delivered rows are waiting to be pruned. Without it, the relay slows down as the table grows.
DBA tip: Include the idempotency key of the originating operation in the payload. It is what lets the consumer deduplicate, and it links the event back to the request that caused it during an investigation.
Step 2 — Build a relay that can run in parallel
The relay claims rows without blocking other instances:
# relay.py — run two or more instances; SKIP LOCKED keeps them from colliding
BATCH = 200
def tick(shard, broker):
with shard.transaction() as tx:
rows = tx.query("""
SELECT id, created_at, aggregate_id, event_type, payload
FROM outbox
WHERE delivered_at IS NULL
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT %s
""", (BATCH,))
for row in rows:
try:
broker.publish(topic=row.event_type,
key=row.aggregate_id, # ordering key
value=row.payload)
tx.execute("""UPDATE outbox SET delivered_at = now()
WHERE id = %s AND created_at = %s""",
(row.id, row.created_at))
except BrokerError:
tx.execute("""UPDATE outbox SET attempts = attempts + 1
WHERE id = %s AND created_at = %s""",
(row.id, row.created_at))
break # stop the batch; the next tick retries from here
Operational note: Marking delivery inside the same transaction that claimed the row means a relay crash after publishing but before the update re-publishes the event. That is the at-least-once guarantee, and it is why the consumer must deduplicate.
DBA tip: Break out of the batch on the first failure rather than continuing. Continuing past a failing broker publishes later events before earlier ones for the same aggregate, which quietly violates the ordering the key was meant to provide.
Step 3 — Preserve per-aggregate ordering while scaling out
Global ordering is unnecessary and expensive; per-entity ordering is usually required:
-- claim by hash bucket so one aggregate is only ever handled by one relay instance
SELECT id, created_at, aggregate_id, event_type, payload
FROM outbox
WHERE delivered_at IS NULL
AND hashtext(aggregate_id) % %(relay_count)s = %(relay_index)s
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 200;
Operational note: Changing the relay count re-assigns buckets, so drain the outbox before scaling relays up or down, or accept a brief window where two instances could handle the same aggregate.
SRE tip: Use the same hash the broker uses for partition assignment where possible, so an event’s path from outbox to consumer stays on one ordered lane end to end.
Step 4 — Monitor age, and prune delivered rows
CREATE OR REPLACE VIEW v_outbox_health AS
SELECT count(*) FILTER (WHERE delivered_at IS NULL) AS undelivered,
coalesce(extract(epoch FROM now() - min(created_at))
FILTER (WHERE delivered_at IS NULL), 0) AS oldest_seconds,
count(*) FILTER (WHERE delivered_at IS NULL AND attempts > 5) AS poisoned
FROM outbox;
# the alert that matters: age, not count
outbox_oldest_seconds > 300
outbox_poisoned > 0
Delivered rows are pruned by dropping whole partitions, exactly as in partition lifecycle and retention management — a DELETE FROM outbox WHERE delivered_at IS NOT NULL on a high-throughput table is a bloat generator.
Operational note: A row with many attempts is poison — usually a payload the consumer rejects. Move it to a dead-letter table rather than letting it block its aggregate’s ordering forever.
SRE tip: Chart oldest_seconds next to broker lag. A rise in the first with a flat second means the relay is the bottleneck; both rising together means the broker or the consumer is.
Verification
Kill the relay mid-flight and confirm nothing is lost:
# generate 1,000 events, kill the relay after ~200 are delivered, restart it
psql -c "SELECT count(*) FROM outbox WHERE delivered_at IS NULL"
count
-------
812
After restarting the relay, the undelivered count must reach zero and the consumer must have received at least 1,000 events with exactly 1,000 distinct idempotency keys — the duplicates being the at-least-once behaviour working as designed.
Failure mode table
| Failure mode | Root cause | SRE mitigation |
|---|---|---|
| Outbox grows without bound | the relay stopped and nothing alerted, or delivered rows are never pruned | alert on oldest_seconds, not on row count; drop delivered partitions on a schedule |
| Events for one entity arrive out of order | two relay instances handled the same aggregate, or the batch continued past a failed publish | claim by hash bucket so an aggregate maps to one relay; break the batch on the first failure |
| A poison event blocks its aggregate forever | the consumer rejects a payload permanently and the relay retries it indefinitely | move rows past an attempt threshold to a dead-letter table and alert; never silently drop them |
FAQ
Why not just publish to the broker after committing?
Because the process can die between the commit and the publish, and nothing records that the publish is owed. The outbox writes the intent inside the same transaction as the data change, so the two are atomic by construction. A relay then delivers it, and can retry forever because the intent is durable.
Does the outbox preserve event ordering?
Per aggregate, yes, if the relay processes rows for the same key in id order and does not run two workers on the same key. Globally, no — and it should not try. Claim rows with SKIP LOCKED partitioned by a hash of the aggregate id so parallel relays never interleave events for the same entity while still scaling out.
How large should the outbox table get?
It should be nearly empty in steady state. A growing table means the relay is slower than the write rate or has stopped. Alert on the age of the oldest undelivered row rather than on row count, because a backlog of a million rows delivered within a second is healthy and a single row stuck for an hour is not.
Related
- Distributed Transactions & Cross-Shard Consistency — the parent topic and where the outbox fits among the alternatives
- Idempotency Keys for Safe Cross-Shard Retries — what consumers need to make at-least-once safe
- Partition Lifecycle & Retention Management — pruning the outbox by dropping partitions rather than deleting rows