Lesson 6 · Final · Milestone: No duplicates

Final: a checkout that never charges twice

Five lessons, four files in src/. Now you compose them into one checkout, and prove it correct by attacking it with every fault the course has shown you.

By the end you'll be able to build a checkout flow that charges and ships each order at most once under every fault the course has shown you, and show it with a chaos test that checks the real ledger rather than your code's opinion of itself. That's the mission's last milestone.

invariantA statement that must be true at the end of every run, whatever failed. "≤ 1 charge per order."
ground truthThe state you can't fool yourself about: PayCo's ledger, the shipments table.
crash pointA named place where the test kills our own process mid-flow.
reconciliationAsking PayCo, after the dust settles, what really happened to orders we're unsure about.

The whole flow on one page

Every box below is a lesson you've already done. The only new code is the glue in src/checkout.ts: checkout() and reconcile().

Clientsame key checkout() · inside store.run(key, req, …) L4 check orderexists · amount charge PayCokey charge:${orderId} withTimeout (L2) inside retry (L3) markPaid+ outbox · 1 tx ✕before-charge ✕after-charge ✕after-commit paid → return it · 'failed' → ValidationError PayCo 400 → 'failed' · else (timeout, 503, 422) → 'unknown' error writes only WHERE status = 'pending', then rethrow any error releases the key → client retry runs it again success → response saved under the client key relayOnceat-least-once L5 Shippinginbox dedupe L5 reconcile()ask PayCo about unknowns lookup fails → unresolved, next pass ✕ = crash points the test injects
The reference version is solutions/checkout.ts (~110 lines with comments). Build yours from your own src/ files first.

Mental model: every step is a door that's safe to walk through twice. You can't stop failures, and you can't know where the last attempt stopped. So you make each step re-runnable: the claim is atomic, the charge carries a key PayCo dedupes on (sent with withTimeout inside retry, defaults { maxAttempts: 4, baseMs: 5, capMs: 40 }, which the tests assume. The lab bounds each call and the number of attempts, not the whole request: it doesn't thread Lesson 2's Deadline through checkout, and chaos runs without a retry budget. In production you'd pass both in.), markPaid only acts if the order isn't paid yet, and the consumer remembers messages it has applied. Then "start again from the top" is always a correct recovery, whoever does it: a client retry, a takeover after a crash, or reconciliation. The model breaks down at the edges of your system. A step that's re-runnable only within 24 hours (Stripe may prune keys "after they're at least 24 hours old"[1]) stops being safe after that.

Two keys, two jobs

The flow uses two idempotency keys, and confusing them is the classic bug:

The second key is what makes the first one's "release on error, run again" policy safe. Brandur Leach calls charges like this foreign state mutations: "We've pushed data into a system beyond our own boundaries and we shouldn't lose track of it"[2]. You can't roll a charge back with your database transaction, so you make it repeatable instead.

Crash points: what makes each one safe

Our process dies…World after the crashWhy the retry is still correct
before-chargenothing charged, order pendingThe retry charges for the first time.
after-chargecharged, order still pendingThe retry runs the work again with the same provider key → PayCo returns the same charge → markPaid. After a real process death the client key stays in_flight until its lock goes stale, then a retry takes over (Lesson 4's test). In the lab, SimulatedCrash is an ordinary exception, so it unwinds through store.run, which releases the claim, and the retry simply re-claims it.
after-commitcharged, paid, outbox row written, response not savedThe retry sees the order already paid and returns it. markPaid wouldn't write a second outbox row anyway.

Unknown is not failed

When every attempt times out, checkout sets the order to 'unknown' and rethrows. Marking it 'failed' would often be a lie: a dropped or slow response means the card was charged (Lesson 1). Only a 400 (PayCo rejected the request as invalid, before doing anything) means "not charged", so only that sets 'failed'. A 503 also means "not charged", but only for that one attempt: once retries run out, the attempts may have mixed 503s with timeouts, so the order becomes 'unknown' and reconcile decides. A 422 from PayCo means charge:o7 was already used with other parameters, so a charge may exist: that's a bug to alert on, never 'failed'.

Both error writes carry a guard: WHERE status = 'pending'. Two requests can work on o7 at once (a second tab, or a takeover while the first worker still waits on PayCo). If one has already paid it and the other then writes 'unknown' over 'paid', reconcile later calls markPaid again, which writes a second outbox row, and o7 ships twice. So checkout's error writes only move an order out of pending. Don't confuse this with markPaid's own guard from Lesson 5, status != 'paid': that one must stay, because reconcile needs it to move unknown to paid.

Then who resolves 'unknown'? Kleppmann's answer, from Lesson 1: "the shop can wait until the connection is restored, and then query the payments service to find out the status of any transactions whose outcome was unknown"[3]. That's reconcile(). For every pending or unknown order it asks PayCo with findByOrder, sent through deps.net with timeout and retry like the charge (it's a read, so retrying is safe):

The lookup…Then
finds a chargemarkPaid with PayCo's charge id
finds no charge'failed'
itself fails (timeout, 503)leave the order alone, count it unresolved, try on the next pass. A failed lookup proves nothing.

reconcile() returns the three counts, { paid, failed, unresolved }.

The timing trap. Reconcile only once in-flight requests have settled. If a slow charge lands after reconcile said "failed", you have a charged customer and a failed order. The chaos harness waits for net.idle() first. Real systems can't wait forever, so they lean on the other half of Kleppmann's note: "a payment is something that can be undone". When reconciliation finds a charge for an order you've already given up on, you refund it, or void it (cancel before it settles).

Real providers are messier than PayCo. The lab's findByOrder is always up to date. Real lookups may not be. Stripe's Search, for example: "Don't use search for read-after-write flows… Under normal operating conditions, data is searchable in under 1 minute"[4]. A reconcile built on it would mark freshly charged orders failed. So in production:

Follow one order through every fault the harness can throw at it. Press ▶, use the chapters, or turn on 🔇 narration.

Testing: ask the ledger, not your code

A unit test asks "did checkout() return paid?". That's your code grading itself. The chaos test in lib/chaos.ts builds a fresh world per seed: 12 orders, clients that retry with the same key, 30% double clicks, some clients that walk away after an error, some orders reopened in a second session with a new client key, 8% of each network fault, and our process crashing at random crash points 10% of the time. Then it runs recovery: wait for the network, reconcile once while PayCo's lookups are down (nothing may become failed), reconcile again, and drain the outbox through a relay and a consumer that both crash. Then it checks ground truth. For every order:

InvariantChecked againstMainly protected by
charged at most oncePayCo ledgerprovider key charge:${orderId}
shipped at most onceshipments tableconditional markPaid + consumer inbox
no order left pending / unknownorders table after recoveryreconcile()
paid ⇔ charged, and the order's charge_id is PayCo'sorders vs ledger"unknown ≠ failed" + reconcile
paid ⇔ shippedorders vs shipmentsoutbox in the same transaction
every "paid" told to a client is trueclient answers vs orderssaved response under the client key
nothing marked failed while lookups were downorders after the outage pass"a failed lookup proves nothing"

This is property-based testing with faults: instead of a few hand-picked examples, you state what must always hold and let many random worlds hunt for a counterexample. Every world is seeded. A seed fixes the fault schedule, but the network uses real timers, so the interleaving, the counts and the charge ids shift a little between runs of the same seed. Use one seed to look at a world; confirm a bug or a fix across many seeds (SEEDS=50 or more). On the reference solution:

One example run. Counts, fault counts and charge ids vary run to run.

$ IMPL=solutions npm run chaos
Chaos · 50 world(s), crash rate 0.1, network faults {"drop-request":0.08,…}

  orders   paid   failed   charges   shipments   crashes   client errors   network calls
     600    594        6       594         594       376             536            1275

  faults injected: ok 839 · drop-response 109 · slow 111 · reject-503 98 · drop-request 118
  recovery: 65 clients walked away · reconcile resolved 2 → paid, 6 → failed · 8 lookups failed

  ✓ every invariant held: ≤1 charge and ≤1 shipment per order, paid ⇔ charged ⇔ shipped,
    no order left unknown, and no order marked 'failed' while PayCo's lookups were down.

Read it as a ledger: 594 paid orders, 594 charges, 594 shipments, and 6 orders that were never charged and are honestly failed. The recovery line shows reconcile doing real work. The counts move between runs; the invariants don't.

A test is only as good as the bugs it catches

A green chaos run means nothing until you've seen it go red. So break the code on purpose (this is called mutation testing). IMPL=<folder> makes the tests and demos load any folder directly inside labs/payflow (a nested folder breaks the ../lib imports):

cd labs/payflow
cp -r src mutant                # your own code (never edit src/ itself)
# edit mutant/checkout.ts: delete the provider's idempotencyKey
IMPL=mutant SEEDS=50 npm run chaos   # → results/06-chaos.mutant.json, not your evidence file
rm -rf mutant

Use at least 50 seeds (200 is better): some bugs only show when several faults line up on one order. One example run (counts and charge ids vary run to run):

  orders   paid   failed   charges   shipments   ...
     600    594        6      1001         594

  ✗ 50 world(s) broke an invariant. First ones:
    seed 1: o1-4: charged 4 times (ch_10, ch_14, ch_20, ch_23)
    seed 3: o3-12: client was told paid/ch_13, truth is paid/ch_10

  Look at one world: IMPL=mutant SEED=1 npm run chaos
  A seed fixes the fault SCHEDULE, not the timer interleaving …

Only a full run of src/ (no SEED, SEEDS ≥ 50) writes your evidence file results/06-chaos.json. Mutant and single-seed runs write results/06-chaos.<label>.json and say so, so experimenting can't overwrite your evidence.

We tried many deliberate bugs against this lab. Chaos catches the big ones: no provider key (charged 2–4×), a provider key copied from the client key (charged twice when a second session reopens the order), an unconditional markPaid and a consumer without dedupe (shipped 2×), "timeout ⇒ failed" and a reconcile that marks orders failed when the lookup fails (charged but order is 'failed'; these need several faults on one order, so they show at 50+ seeds). Others pass chaos and are caught only by a unit test. A key claim that isn't exclusive (an upsert, an INSERT that overwrites an existing row instead of failing) passes chaos because the provider key absorbs the duplicate work. That's defence in depth hiding a bug. Lesson 4's test "concurrent duplicate → ConflictError" still catches it, and the double-click and 422 tests in test:06 catch a checkout that skips the store altogether. Layers protect your customers; per-layer tests keep each layer honest, so a hole in one doesn't stay hidden behind another.

Reading a test before you write one

Your challenge includes writing one test. Here is an existing one from test/06-checkout.test.ts, annotated:

test('lost response, then retries: still ONE charge', async () => {
  const d = world(['drop-response', 'drop-response']);   1
  const res = await checkout(d, req);                     2
  assert.equal(res.status, 'paid');
  assert.equal(d.provider.charges.length, 1);             3
});
  1. A scripted world. world(script) builds a fresh DB, PayCo and network with order A (500¢). The script is the fault for each network call, in order; after it runs out, every call is ok. So: attempts 1 and 2 lose their responses, attempt 3 succeeds.
  2. Act like a client. req is { key: 'client-1', orderId: 'A', amountCents: 500 }. Call checkout as many times, and as concurrently, as your scenario needs.
  3. Assert on ground truth. Not just the return value: PayCo's ledger (d.provider.charges) and the order row (getOrder(d.db, 'A')).

Now the same shape with blanks: a double click while the first request is still in flight. (It's in the suite as test 6; write it from memory before you look.)

test('double click while the first is slow → one charge', async () => {
  const d = world([/* fault for the first charge call: ___ */]);
  const first = checkout(d, req);                    // don't await yet
  await assert.rejects(checkout(d, req), /* which error? ___ */);
  assert.equal((await first).status, 'paid');
  assert.equal(d.provider.charges.length, /* ___ */);
});

Which mutants turn this test red? A checkout that skips the client-key store, or a claim that isn't exclusive: both let the second click through instead of a 409. Removing the "already paid → return it" check does not: the 409 fires before that check is reached. That check has its own test, "crash right after commit", which asserts that PayCo isn't called again (d.net.log stays the same length). Pick your assertions to match the bug you want to catch: ground truth alone can't see a wasted call that PayCo deduplicated.


Practice: from memory

These mix all six lessons on purpose. No scrolling up.

Every attempt to charge o4 timed out. Later, reconcile() finds charge ch_9 for o4 at PayCo. What is o4's final status?

Which idempotency key should the shop send to PayCo with the charge?

Why is it safe for retry() to resend a charge that timed out?

The relay crashes after publishing, before marking the row published. What prevents a second shipment?

When should reconcile() decide that an order was never charged?

The chaos test checks its invariants against what?

Put the flow in order

One successful checkout, from the client's request to the shipment. Click the steps in order.

  1. Claim the client key in idempotency_keys
  2. Check the order exists, the amount matches, it isn't paid
  3. Charge PayCo with key charge:o7, timeout + retry
  4. Mark paid and insert the outbox row, one transaction
  5. Save the response under the client key
  6. Relay publishes order.paid
  7. Shipping records the message id and creates the shipment

Which invariant catches it?

🏆 Final challenge: No duplicates

Implement src/checkout.ts (checkout and reconcile) on top of your src/ files from Lessons 2–5, then prove it. solutions/checkout.ts exists, but copying it defeats the point, and the evidence must come from src/ (the results file records "impl": "src").

cd labs/payflow
npm test                        # all 57 tests green, including test:06
SEEDS=200 npm run chaos         # → results/06-chaos.json  (your evidence)
SEED=17 npm run chaos           # look at one world → results/06-chaos.seed17.json
  1. Build it. Follow the numbered contract in the stub's comments. Call deps.crash?.(…) at the three crash points, or the harness can't attack you.
  2. Make it pass. npm test green and SEEDS=200 npm run chaos with every invariant holding.
  3. Prove the tests have teeth. Introduce two deliberate bugs, one at a time: (a) remove or randomise the provider key, (b) mark the order failed when retries time out. Run each with SEEDS=50 or more (bug b needs several faults on one order), capture the chaos output that catches it, including the seed, then revert.
  4. Add one test of your own to test/06-checkout.test.ts for a scenario no existing test covers. For example: a second tab, where every attempt of the first request loses its response (the order ends unknown, but PayCo charged), then the same order arrives with a different client key; assert one charge. It goes red on a mutant whose provider key is copied from the client key. Or invent your own. Show it failing on a broken version and passing on yours, and choose assertions that can see the bug (see the note under the skeleton).
  5. Write it up (one page max): the invariants in your own words; which technique protects which invariant; and one failure your system still can't handle, with what you'd add for it (a late charge after reconcile said failed, idempotency keys pruned after 24 hours, PayCo down for a day…).

Send me back: the npm test summary, results/06-chaos.json, the two mutation outputs (and their 06-chaos.mutant.json), the diff of your new test (plus its failing and passing runs), and the write-up.

Pass criteria (check yourself before sending):

When I've reviewed it, I'll write a learning record and unlock No duplicates, the mission's final milestone. Your labs/payflow is then a small portfolio piece: a tested, failure-proof checkout you can explain line by line.

Read or watch next

Primary source: Implementing Stripe-like Idempotency Keys in Postgres, Brandur Leach. It's the production version of what you just built: the same idempotency_keys table and locked_at, plus recovery points, so a takeover resumes from the last finished phase instead of starting over.

💬 Stuck on a chaos failure you can't explain? Paste the violation lines and the seed into the terminal. We'll replay it and walk the trace together, the same way you'd debug a real incident.