Modules 12 and 15 proved duplicates are inevitable — retries and at-least-once delivery guarantee them. This module makes duplicates harmless: idempotency keys, storage design, natural idempotency, and the fencing that makes redrive and retry safe by construction.
~2.5h study~1.5h exercise1 interactive simFast track ~70 min
Fast track — dedupe tables in prod?
Compresses to ~70 min. Motivation collapses; key design, the concurrent-duplicate storage subtleties, and natural idempotency stay mandatory.
§01 sim once · 5 min
§02 key design, in full · 15 min · MANDATORY
§03 storage design, in full · 20 min · MANDATORY
§04 natural idempotency, in full · 15 min · MANDATORY
Exercise step 1 · 15 min
STEP 01
The double-charge, live
SKIM
Fast-track takeawayA timed-out purchase call retried without a key = two charges (M12's ambiguous timeout). The same request with an idempotency key = second attempt returns the recorded first result. That's the entire pattern; §02–§03 are the details that make it correct under concurrency.
Purchase, timeout, retry
Request log
Charges table
The first attempt succeeds server-side but the response is lost (timeout). The client — correctly, per M12 — retries.
STEP 02
Key design
MANDATORY
The key identifies an intent, not a request. Generated once at intent creation (user taps "buy" → client mints a UUID) and reused across every retry of that intent — client retries, gateway retries, queue redeliveries. A key minted per-attempt dedupes nothing.
Who mints it: the earliest layer that knows the intent. UI/client for user actions; the producer for events (event ID = idempotency key — your messageId in RabbitMQ headers / Kafka record); the orchestrator for saga steps (M17: sagaId:stepName).
Scope the key: uniqueness per (key, operation, principal) — the same UUID arriving at a different endpoint or from a different user must not collide. Practically: dedupe store keyed on endpoint + userId + key.
Same key + different payload = reject (409/422). It signals a client bug or replay tampering; silently returning the stored result for a different request is corruption. Store a payload hash alongside the key and compare.
Natural keys when they exist:(userId, contentId, billingPeriod) for a subscription charge is better than a UUID — it dedupes across client sessions and even client bugs, because the business itself defines "once".
STEP 03
Storage design — where the bugs live
MANDATORY
Fast trackFull read. Every subtlety here is a production bug you'll otherwise meet: the check-then-act race, the in-progress state, the crash-between, the TTL horizon.
Check-then-act races with itself. Two concurrent duplicates (client retry + gateway retry landing together) both read "no record", both execute. The reservation must be atomic: INSERT ... ON CONFLICT DO NOTHING (winner proceeds, loser reads the record) or Redis SET key state NX. Never SELECT-then-INSERT.
Three states, not two:IN_PROGRESS → COMPLETED(result) | FAILED. A duplicate arriving during IN_PROGRESS must wait or 409-retry-later — not execute, and not receive a fabricated success. Returning 202/retry-after for in-progress is honest and simple.
Crash while IN_PROGRESS: the record wedges, blocking the legitimate retry forever. Lease it: IN_PROGRESS carries an expiry; a duplicate after expiry may take over — which is safe only if the underlying operation is itself resumable/idempotent one level down, or reconciled (this is where idempotency meets M19's fencing).
Atomicity with the business effect: the gold pattern — dedupe record and business write in the same DB transaction (dedupe table in the service's own schema). Redis-based dedupe + DB business-write is two systems, and the crash between them recreates the duplicate (record says done, work isn't) or the loss (work done, record missing → re-execution). Same-transaction or consciously accept the gap.
Retention = your maximum duplicate horizon: keys must outlive the longest possible redelivery — client retry windows (minutes) < queue redrive from DLQ (days!) < Kafka replay (your retention). 24h–7d typical; partitioned/TTL'd tables keep it cheap. A redrive older than your dedupe TTL is a duplicate generator.
Store the full first response (status + body), so duplicates receive byte-identical answers — including the original failure: a deterministic business failure (card declined) replays as the same failure rather than re-attempting the charge.
STEP 04
Natural idempotency: design it away
MANDATORY
The best dedupe table is the one you don't need. Operations idempotent by construction:
Absolute over relative:SET resumePoint=1423s replays harmlessly; ADD 10s compounds. State-carrying events over deltas (M15 §04) — the same principle, wire edition. Where deltas are unavoidable (counters), version-guard them: UPDATE ... WHERE version = :expected.
Upserts with deterministic ids: "create entitlement" as INSERT (userId, contentId, ...) ON CONFLICT (userId, contentId) DO UPDATE — the unique constraint is the idempotency mechanism, enforced by the database with zero extra machinery.
Version/timestamp guards for LWW updates: apply event only if event.version > row.version — makes consumers tolerant of duplicates and reorder simultaneously (one guard, two M15 problems solved).
Where it can't be designed away — external side effects: charging a PSP, sending a push, calling the DRM vendor. There you pass your idempotency key downstream (every serious PSP accepts one) and keep the §03 record for the ones that don't.
Staff expectationThe review reflex: for every write endpoint and every consumer, ask "what happens if this executes twice, concurrently, ten minutes apart?" and require the answer to be "nothing" by one of: natural idempotency (preferred), same-transaction dedupe, or a documented accepted risk. Making that question a PR-template checkbox does more for your estate than any single implementation.
STEP 05
Code: the same-transaction pattern
SKIM
Fast-track takeawayOne snippet: atomic key reservation via ON CONFLICT, three-state handling, business write in the same transaction, stored response replay. It's ~30 lines and it's the whole pattern — compare against your services' interceptor.
@TransactionalpublicPurchaseResultpurchase(String key, String userId, PurchaseReq req) {
int reserved = jdbc.update("""
INSERT INTO idem (key, user_id, endpoint, payload_hash, state)
VALUES (?, ?, 'purchase', ?, 'IN_PROGRESS')
ON CONFLICT (key, user_id, endpoint) DO NOTHING""",
key, userId, hash(req));
if (reserved == 0) { // duplicate — we lost the race or arrived latervar rec = idemRepo.find(key, userId, "purchase");
if (!rec.payloadHash().equals(hash(req)))
throw newConflictException("same key, different payload"); // client bug — surface itreturn switch (rec.state()) {
case COMPLETED -> rec.storedResult(); // replay the first answer, verbatimcase FAILED -> rec.storedResult(); // deterministic failures replay toocase IN_PROGRESS -> throw newRetryLaterException(); // 202 + Retry-After; never re-execute
};
}
PurchaseResult result = doPurchase(userId, req, key); // key passed to PSP downstream too
idemRepo.complete(key, userId, "purchase", result); // SAME transaction as doPurchase's writes:return result; // commit is atomic — no gap to crash into
}
STEP 06
Exercise
MANDATORY
Fast trackStep 1 (~30 min): racing your own implementation with concurrent duplicates is the test that matters.
1
Build and race it. Implement §05 against Postgres. Attack it: 50 concurrent identical requests (same key) — assert exactly one charge row; then the SELECT-then-INSERT version — count the duplicates it lets through. Then kill the JVM mid-IN_PROGRESS and verify the lease-expiry recovery path.
2
Close the M15 loop. Take Week-3 Exercise 15.1's duplicate-producing consumer and make it harmless two ways: (a) dedupe table on messageId in the same transaction as the entitlement write; (b) redesign the event as state-carrying + version-guarded upsert with no dedupe table. Argue in two sentences which you'd ship.
3
Paper. Inventory your platform's top 10 write operations and consumers: classify each as naturally idempotent / keyed / unprotected. The unprotected ones ranked by blast radius = a prioritized reliability backlog you can hand to teams tomorrow.
Self-check
Why must the dedupe check be atomic with reservation, not read-then-write?
Duplicates arrive concurrently by nature (retry + original racing, redelivery + redrive): both read "absent", both proceed. Only an atomic claim — unique-constraint INSERT, SET NX — collapses the race to one winner; the loser learns the truth from the conflict.
Why replay the stored FAILURE for a duplicate instead of retrying the operation?
If the failure was deterministic (card declined, invalid content), re-executing re-runs a side-effecting attempt the business already answered — possibly re-pinging the PSP, re-alerting fraud, or now-succeeding against changed state the client isn't expecting. The key's contract is "one intent, one outcome"; a new attempt deserves a new key, which is precisely how the client signals "the user tried again".
Your dedupe lives in Redis with TTL 1h; DLQ redrive happens after 3 days. What breaks?
Every redriven message is past the dedupe horizon — all reprocess as fresh, duplicating whatever they did the first time. Retention must exceed the longest redelivery path (DLQ age limit, Kafka retention). Fix: dedupe TTL ≥ max redrive age, or naturally idempotent consumers so the horizon doesn't matter — the stronger fix.
Rank for a "grant entitlement" consumer: dedupe table vs version-guarded upsert. Which and why?
Version-guarded upsert on (userId, contentId): the DB constraint is the mechanism — no extra table, no TTL horizon, no crash-between gap, and it also absorbs reorder. Dedupe tables are for operations without a natural unique target (a PSP charge). Rule of thumb: reach for the dedupe table only after failing to find the natural key.