Reference · Lesson 6

The whole flow

A checkout that charges and ships each order at most once, and the test that proves it. One page.

The rule

Make every step safe to run twice. 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 flow: decisions, not code

stepdecisionfrom
1. wrapeverything runs inside the client-key storeL4
2. checkmissing order / wrong amount → ValidationError · already paid → return it, no call · failed → ValidationError · pending or unknown → continue
3. chargeper-call timeout inside retry (defaults { maxAttempts: 4, baseMs: 5, capMs: 40 }), through net, with a provider key derived from the orderL2 · L3 · L4
4. on error400 → failed · anything else (timeout, 503, 422) → unknown, never failed (alert on 422) · these writes only leave pending · rethrowL1
5. commitmarkPaid: order + outbox row in one transaction, guard != 'paid'L5
crash pointsbefore-charge · after-charge · after-commit

Reconcile

for each pending / unknown order, the lookup (through net, timeout + retry)…then
finds a chargemarkPaid with PayCo's charge id
finds nonefailed
itself failsleave it; count unresolved; next pass

Run it only after in-flight requests settle. Returns { paid, failed, unresolved }. Then relayOnce → ShippingConsumer.handle: at-least-once + inbox = exactly-once effect.

Two keys

keyexampleprotects
client keyclient-key-o7the request to the shop: retries get the saved response
provider keycharge:o7the charge: any path to PayCo for this order makes ≤ 1 charge

Crash points

crash atworld afterretry is safe because
before-chargenothing chargedit charges for the first time
after-chargecharged, order pendingretry re-runs with the same provider key → same charge. Real death: key stays in_flight until stale → takeover. Lab's SimulatedCrash unwinds through store.run, releasing the claim, so the retry just re-claims.
after-commitpaid + outbox row, response unsavedorder already paid → returned; markPaid is conditional

Invariants (lib/chaos.ts, checked per order against ground truth)

invariantprotected by
≤ 1 charge (PayCo ledger)provider key
≤ 1 shipmentconditional markPaid + consumer inbox
no order left pending / unknownreconcile()
paid ⇔ charged; charge_id matches PayCounknown ≠ failed + reconcile
paid ⇔ shippedoutbox in the same transaction
every "paid" told to a client is truesaved response under the client key
nothing marked failed while lookups were downa failed lookup leaves the order unresolved

Chaos worlds also include clients that walk away, second sessions with a new client key, a reconcile pass during a lookup outage, and crashes inside the relay and the consumer.

Commands

npm run test:06                 # 12 tests, the last is a 25-seed chaos run
SEEDS=200 npm run chaos         # → results/06-chaos.json (the evidence: src/, ≥ 50 seeds)
SEED=17 npm run chaos           # look at one world → results/06-chaos.seed17.json
IMPL=solutions npm run chaos    # what passing looks like → 06-chaos.solutions.json
CRASH=0.3 npm run chaos         # more crashes → 06-chaos.crash0.3.json

A seed fixes the fault schedule, not the timer interleaving: the same seed can break on a different order, or pass. Confirm bugs and fixes with SEEDS=50+.

Mutation check: cp -r src mutant (directly inside labs/payflow; nested folders break the ../lib imports), break one thing, IMPL=mutant SEEDS=50 npm run chaos (200 is better), then rm -rf mutant. Mutant runs write 06-chaos.mutant.json, never your evidence file.

bugcaught by
no provider key · provider key = client keychaos: charged 2–4×
unconditional markPaid · consumer without dedupechaos: shipped 2×
timeout ⇒ failed · reconcile marks failed when the lookup failschaos (50+ seeds) + test:06: charged but order is 'failed'
checkout skips the client-key storetest:06 double click (409) and 422
"already paid → return it" removedtest:06 after-commit (asserts no new PayCo call)
non-exclusive key claim (upsert)test:04 only; chaos passes (masked by the provider key)
SELECT-then-INSERT claim · takeover without compare-and-setnothing in this lab (one synchronous process can't race); real in production

Still not handled

Sources