Durable Work in PostgresPart 3
Many workers
Multi-worker contention, delivery guarantees, and per-key ordering before consistent hash.
Traffic doubles, the queue falls behind, and the obvious fix is a second worker. Soon one worker sends the receipt for order:9182 while another processes a refund for the same order. Both rows were claimed correctly, but the customer receives the messages in the wrong order because nothing kept that order’s stream together.
Adding processes increased capacity, but it also exposed an unanswered question: which work may run concurrently, and which work must stay in order?
Multiple workers can safely claim different rows from one queue. That pattern is called competing consumers. It increases throughput, but it does not provide ordering. This article separates three promises that are easy to blur together: at-least-once delivery, deduplication, and ordering for one business key such as an order.
Competing consumers, same inbox
The producer inserts into inbox, and every worker runs the same loop: clean up expired leases,
claim with SKIP LOCKED, handle the payload, and record completion. There is no central dispatcher
choosing a worker. Postgres serializes the claim on each row, while SKIP LOCKED lets losing workers
continue to other available rows instead of waiting for that row’s lock.
That pattern is Competing Consumers: many workers, one queue table, and row locks instead of a broker assigning jobs. It scales throughput, but it does not automatically pin order:9182 to one process.
Workers registry and shared inbox
Open in Studio| Concern | Mechanism here |
|---|---|
| Throughput across machines | Many workers + SKIP LOCKED |
| Crash recovery | Leases + lease cleanup |
| Duplicate inbox rows | idempotency_key UNIQUE |
| Duplicate side effects | Idempotent handlers + fencing |
| Work for one key runs in order | Oldest-unfinished sibling guard |
How to scale past one worker
There are three practical ways to coordinate multiple workers on the same inbox, from simplest to most controlled:
- Any worker claims any row: simplest multi-process setup. Throughput increases, but there is no per-key affinity or ordering. Use when jobs are independent.
- Per-key ordering guard: sibling check on claim gives FIFO within one
partition_keywithout a coordinator. Hot keys bottleneck one worker. - Consistent hash + heartbeats: stable process affinity for cache locality or expensive per-key state. A new worker receives about
1/(N+1)of the keys from the existing pool. This is an optimization, not a replacement for database ordering or idempotency.
Delivery guarantees to document
More workers increase throughput. They do not create ordering.
Once you have two workers, write down the delivery promise before you ship. “The queue is distributed” is not a useful guarantee. The table below separates whether a row will be attempted, whether two rows for one order may overlap, and whether repeating an attempt can duplicate an external effect. Each promise needs a different mechanism.
| Promise | Status | Notes |
|---|---|---|
| Row delivery | At-least-once | Leases + lease cleanup. May retry unless dead_letter |
Per-partition_key order | Conditional | Only with ordering guard + idempotent handler |
| Duplicate inbox rows | Prevented | idempotency_key UNIQUE on enqueue |
| Duplicate side-effects | Your responsibility | Idempotent handlers. Fencing at downstream stores |
| Global FIFO | Not provided | By design. Single partition_key or ordered log |
Idempotency at enqueue and in the handler
At enqueue: a unique constraint on idempotency_key prevents a producer retry from creating two
queue rows for the same logical job. This deduplicates admission to the queue; it does not prove the
handler ran only once.
In the handler: Worker may process the same row twice after reclaim. Check “already done” before side effects.
Per-key ordering
Same partition_key does not imply FIFO unless you opt in. The claim query must select only the
oldest unfinished row for each key. Checking only for a processing sibling is not enough: one
batch could otherwise claim several pending rows for the same order at once.
WITH candidates AS (
SELECT i.id
FROM inbox i
WHERE i.status = 'pending'
AND i.available_at <= now()
AND NOT EXISTS (
SELECT 1
FROM inbox busy
WHERE busy.partition_key = i.partition_key
AND busy.status = 'processing'
)
AND NOT EXISTS (
SELECT 1
FROM inbox earlier
WHERE earlier.partition_key = i.partition_key
AND earlier.status = 'pending'
AND (earlier.created_at, earlier.id) < (i.created_at, i.id)
)
ORDER BY i.created_at, i.id
LIMIT 25
FOR UPDATE SKIP LOCKED
)
UPDATE inbox i
SET status = 'processing',
claimed_by = $1,
lease_expires_at = now() + interval '90 seconds',
lease_generation = i.lease_generation + 1,
attempts = i.attempts + 1
FROM candidates c
WHERE i.id = c.id
RETURNING i.*;
The (created_at, id) comparison gives ties a stable order. Including both pending and
processing siblings means a retrying head item blocks later work for the same key. That is strict
FIFO. Do not pay for it when later work is allowed to pass a delayed retry.
| Guarantee | How | Breaks when |
|---|---|---|
FIFO per partition_key | Claim only the oldest unfinished sibling | A delayed retry blocks later work for the same key |
| No duplicate inbox rows | ON CONFLICT against the idempotency index | Producer omits key |
| No duplicate side-effects | Handler checks processed-keys / fence token | Handler is not idempotent |
| Stable affinity per key | Not in this chapter. Needs consistent hash. |
Ordering is opt-in. Competing consumers provide parallelism, not automatic serialization.
Add the sibling guard when per-key FIFO matters.
Where this breaks down
I do not add affinity until the workload needs it, but I make these limits explicit:
- No process affinity. Correctly ordered work may move between workers. That only matters when the handler benefits from local cache or other process-local state.
- Hot
partition_key. One tenant stream bottlenecks whichever worker wins the lock. Shard hot keys explicitly. - Rebalance window. When you add consistent hash, keys move while old leases may still be in flight. Plan for fencing during rebalance.
- Split-brain after long pauses. A stale worker must fail
completeand lose fence tokens downstream.
Source
These companion files collect the SQL and TypeScript used in this article. The link labels distinguish full implementations from focused examples and worker skeletons.