BitBootcampBitBootcamp ← all explainers 🔄 Review ⚙ Settings

Drill 85 — The Duplicate Delivery

Theme: Queues & Backpressure · Format: 20-min drill · Difficulty: core

The story

A payments platform runs a settlement consumer: it reads payment instructions off a broker, posts a double-entry row to the ledger, and hands the outbound wire to the rail. The broker is a standard managed queue with the standard managed guarantee — at-least-once delivery. The team has read that phrase a hundred times. Nobody has ever sat with what the least in "at least" costs them.

The consumer's loop is the obvious one: pull a message, post the ledger entry, ship the wire, then acknowledge the message so the broker drops it. It has settled millions of instructions this way. Throughput is fine, the ledger balances every night, and the runbook is one page long.

One Tuesday a settlement pod is evicted mid-batch — a node drains for a kernel patch, right in the window after the consumer has posted the ledger entry and shipped the wire but before it sends the ack. From the broker's point of view, message 42 was delivered and never confirmed. So the broker does exactly what at-least-once promises: when the consumer restarts, it redelivers message 42. The fresh consumer has no memory of the first attempt. It posts the ledger entry again. It ships the wire again. One instruction, two ledger rows, two wires out the door.

By mid-morning the reconciliation team is staring at duplicate debits, a ledger that no longer balances against the rail, and a customer who was paid twice. The consumer code is correct. The broker is behaving exactly as documented.

⏸ Stop here and ask the room: the broker did precisely what "at-least-once" guarantees, and the consumer code has no bug. So where is the defect?

Run it — facilitator script

Timing for a 20-minute session: 2 min story · 10 min staged questions · 3 min reveal · 5 min transfer.

What does "at-least-once" actually oblige the consumer to do?

Name the exact window — what is un-acked but already done?

Ledger post and outbound wire — same fix, or two problems?

The reveal

There was no code bug and no broker bug — there was an unmet obligation. "At-least-once" is a promise the consumer pays for by being idempotent, and this consumer was not. The team added a processed-messages store keyed on the broker message id with a unique constraint: a redelivered id is recognized and the handler short-circuits to just re-acking. The ledger post became an upsert on the natural key (payment_id, account). The wire moved behind a transactional outbox — the ledger row and the outbox intent commit in one transaction, and a relay ships each intent to the rail exactly once using the payment id as the rail's idempotency key. Duplicate settlements went to zero; the duplicate-delivery counter, correctly, kept climbing — because redelivery is normal and now it is simply absorbed.

Why this happens — the mechanics

At-least-once is not a defect; it is the only guarantee a distributed broker can cheaply make. To promise "exactly once" end to end, the broker would need a distributed transaction spanning its own commit and your side effects — the ledger, the payment rail — which it cannot see. So it makes the honest promise it can keep: it will hold a message until you acknowledge, and if the ack never comes, it will deliver again. Every missed ack — a crash, a pod eviction, a consumer-group rebalance, a visibility-timeout expiry under load — becomes a redelivery. Redelivery is not the exception; it is a routine event you are guaranteed to hit.

The bug, then, lives in the seam between the effect and the ack. A non-idempotent handler treats "I ran the effect" and "the broker knows I ran it" as one atomic step, but they are two, and any crash between them replays the effect. Exactly-once delivery is a fantasy across a boundary the broker can't transact over; exactly-once effect is achievable, and it lives entirely on your side — dedupe the message id, upsert on the natural key, and route external side effects through a transactional outbox so intent and state commit together. The organizational root is that "at-least-once" reads like fine print, so the idempotency obligation it hands the consumer never becomes anyone's explicit task.

Diagrams

Problem — how the failure unfolds:

sequenceDiagram
    autonumber
    participant B as Broker
    participant Co as Consumer
    participant L as Ledger DB
    participant R as Payment rail
    B->>Co: deliver payment 42
    Co->>L: post ledger entry
    Co->>R: ship the wire
    Co--xB: ack never sent
    Note over Co: pod evicted<br/>before the ack
    Note over B: no ack —<br/>at-least-once redelivers
    B->>Co: redeliver payment 42
    Co->>L: post ledger entry again
    Co->>R: ship the wire again
    Note over Co,R: one instruction —<br/>two posts, two wires

Solution — where the guardrails go:

flowchart TD
    A["Redelivered message<br/>(at-least-once)"] --> B["Dedupe by message id<br/>unique processed-store"]
    B -->|"id seen before"| C["Short-circuit — effect<br/>already applied, re-ack"]
    B -->|"new id"| D["Transactional outbox:<br/>ledger row + outbox<br/>intent in one commit"]
    D --> E["Upsert on payment id<br/>INSERT ON CONFLICT"]
    E --> F["Ack only after the<br/>effect is durably committed"]
    F -.-> AL["Alarm: redelivery rate<br/>over one percent or DLQ depth over 0"]
    C -.-> AL

What good looks like

The lesson

At-least-once delivery is a promise the consumer pays for: the broker will sometimes hand you the same message twice, so your handler must make repeating a message a no-op. You cannot buy exactly-once delivery across a boundary the broker can't transact over — but you can build exactly-once effect with a dedupe store, an upsert, and a transactional outbox.

Named concepts: at-least-once delivery, idempotent consumers, transactional outbox, ack semantics.

Real-world echoes

Transfer

Which of your queue consumers would apply a side effect twice if the broker redelivered the current in-flight message right now — a rebalance, a pod eviction, a missed ack? "It probably wouldn't happen" is not an answer; name the dedupe key or the upsert that makes the replay a no-op.

Make it a ticket: inventory every consumer that performs a side effect; for each, add a message-id dedupe store, convert own-database writes to upserts on the natural key, route external effects through a transactional outbox with a rail idempotency key, and add a redelivery contract test — starting with anything that moves money.

Related