Delivered twice.
Counted once.
Every payment event arrives at least once, and sometimes more: retries, timeouts, crashes. Redeliver one: the naive design counts the same money twice, while the idempotent one counts it exactly 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 timeouts and crashes. The naive consumer applies whatever arrives, so a repeat credits the ledger twice and money is created from nothing.
The idempotent consumer records each event id in a processed_events table with a unique constraint, in the same transaction as the ledger write:
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 rolls back, harmlessly. At-least-once delivery plus idempotent processing adds up to effectively-once.
Why a separate table? The consumer's effect is often not one inserted row: it may update a balance or touch several tables, so there is no business row to carry a unique event id. When a handler does insert exactly one row per event, a unique event_id column on that table works just as well.
The cost: a table that grows with every event, one more indexed write, and the dedup key must be the event id, never the payload: two legitimate $20 payments must both count.