Delivered twice.
Counted once.
Brokers deliver at-least-once, so the same event can arrive twice: after a timeout, after a crash, after a retry. Redeliver one: the naive consumer credits the ledger twice; the idempotent one counts it once.
Naive, counts every delivery
- in ledger
- $0
- overcount
- $0
Idempotent, records once
- in ledger
- $0
- deduped
- $0
Deliver a few events, then redeliver one: brokers repeat deliveries after timeouts, retries, and crashes. That delivery was a repeat: the naive ledger counted it again; the idempotent one knew the id and dropped it.
How the idempotent consumer actually works read more
"Exactly-once" delivery is a myth. Brokers redeliver after a timeout or a crash, so the same event can show up more than once. A naive consumer just applies whatever arrives, so a repeat credits the ledger twice and money gets created out of nothing.
The fix: record each event's id in a processed_events table with a unique constraint, in the same transaction as the ledger update:
BEGIN;
INSERT INTO processed_events (event_id) VALUES ($1);
-- unique(event_id): a duplicate aborts the whole transaction
UPDATE ledger SET balance = balance + $2;
COMMIT; A duplicate hits that constraint and the whole transaction rolls back, harmlessly. Put together, at-least-once delivery plus an idempotent consumer gets you effectively-once.
Why a table, not a column? This handler updates a balance, it doesn't insert a fresh row, so there's nothing to attach a unique event id to. A handler that does insert one row per event can skip the table and put a unique event_id column on that row instead.
The trade-off: this table grows with every event, that's one more write to index, and the dedup key has to be the event id, never the payload. Two separate $20 payments still both need to count.