Car Wash Ledger

The numbers behind it.

Claims about money should come with measurements. These are the chaos campaigns and the benchmarks as they came out, read straight from the result files in the repository, including the parts that didn't go my way.

Chaos

Payments run while processes are killed with SIGKILL, Postgres and Kafka crash, the network misbehaves and the bank lies. After each run an independent checker reads the books.

Runs

27

Journeys and journals

74,388

Kills

616

Runs with a violation

0

A zero only means something if the harness can see a violation. So each guard gets a mutant: the same code with that one guard removed. Every one has to be caught, except where another guard is meant to cover for it.

MutantGuard removed
M1no unique reference on journals and attemptspayments: survived 5 runs
M1+M2both of the abovepayments: caught in run 1 by S3, S4, S8
M2no conditional update on payment statepayments: caught in run 1 by S3, S8
M3no inbox dedupe in the consumerspayments: caught in run 1 by L3
M4the relay marks events published before producing thempayments: caught in run 1 by L2, L3
M5no request hash check on idempotency keyspayments: caught in run 1 by S5
M6no amount check on the bank's answerspayments: caught in run 5 by S4
M6no amount check on the bank's answerspayments: caught in run 1 by S4
M7a balance read without its lock and written back (a lost update)ledger: caught in run 1 by S2
M7a balance read without its lock and written back (a lost update)payments: caught in run 1 by S2, S6
M7a balance read without its lock and written back (a lost update)ledger: caught in run 1 by S2
M7aonly FOR UPDATE dropped from the lock (control)ledger: survived 3 runs
M7aonly FOR UPDATE dropped from the lock (control)payments: caught in run 2 by S6
M8webhooks applied as sent instead of pulledpayments: caught in run 1 by S4
M9a snapshot horizon that counts running transactionsledger: caught in run 1 by S2
M9a snapshot horizon that counts running transactionsledger: caught in run 1 by S2
M10the settlement journal leaves the fee outpayments: caught in run 1 by L5
M11the leaf encoding leaves the reference outpayments: caught in run 1 by S7
M12the checkpointer sends the wrong rootpayments: caught in run 1 by S7, L4

M1 survives on purpose: the conditional state update still stops a double capture, and M1 with M2 together is caught at once. M7a is a control: in the ledger harness it changes nothing, and in the payments harness S6 still catches the order it gets wrong. The first M6 campaign needed five runs, because wrong amounts at 0.2% were too rare to test the guard; at 0.5% it was caught in every run.

The hot account

Every payment touches the same fee and receivable accounts, so a plain balance row becomes a lock every payment waits on. Five ways to keep that balance, offered from 50 to 3,200 journals a second, each rate measured for 15 s, three times.

Connections
Journals a second, achievedLocked rowAsync commitSerializableDerivedSharded, 16
02505007501,000501001502003004008001,200journals a second offeredachievedDerivedSharded, 16Async commitLocked rowSerializable
p99 latencyLocked rowAsync commitSerializableDerivedSharded, 16
10 ms100 ms1 s10 s501001502003004008001,200journals a second offeredp99Locked rowAsync commitSharded, 16DerivedSerializable
The numbers as a table
OfferedLocked rowAsync commitSerializableDerivedSharded, 16
5051 · 31 ms51 · 15 ms51 · 60 ms51 · 16 ms51 · 17 ms
100102 · 59 ms102 · 18 ms95 · 77 ms102 · 16 ms102 · 19 ms
150149 · 1.5 s151 · 25 ms133 · 72 ms151 · 17 ms151 · 18 ms
200149 · 5.2 s200 · 36 ms—200 · 18 ms200 · 19 ms
300—307 · 68 ms—307 · 19 ms307 · 17 ms
400—406 · 57 ms—406 · 20 ms406 · 15 ms
800—530 · 4.3 s—802 · 81 ms788 · 285 ms
1,200———915 · 2.7 s743 · 3.9 s

Each cell is achieved journals a second and p99, the median of three runs.

A locked row tops out near 150 journals a second, whatever the connection count, because each commit waits for the one before it to flush. Serializable is worse at every count. Derived balances and a balance split into 16 rows both sustain about 700 at 32 connections, with p99 under 50 ms. The ledger uses the 16 rows, because their reads stay cheap.

The posting path

One journal of three entries, one connection, the locked mode. The same work through four ways of talking to Postgres.

HowRound tripsp50p99Journals a second
Prisma query API817.4 ms30.4 ms61
Prisma, raw statements613.9 ms21.1 ms77
Prisma, one CTE410.8 ms21.7 ms92
pg, one CTE410.1 ms24.8 ms101

Outbox lag

Events are written in the same transaction as the money and a relay publishes them to Kafka. How long that takes, at three steady rates (five runs each, median and range), and what happens when the broker is killed for 30 s at 500 events a second.

Events a secondTo Kafka, p50To Kafka, p99To the consumer, p99Transactions an event
10020.2 ms46.1 ms (45–47)64.9 ms (63–67)5.48
50016.4 ms36.0 ms (35–38)94.3 ms (91–101)2.68
2,00021.1 ms46.0 ms (45–279)146.0 ms (134–349)1.35
  • Broker killed, run 1

    Back after 44.1 s, first publish 6.5 s later. 25,084 events waited and drained in 1.9 s at 13,131 a second; latency was normal again 54.9 s after the broker came back.

  • Broker killed, run 2

    Back after 43.8 s, first publish 6.7 s later. 24,912 events waited and drained in 1.9 s at 13,304 a second; latency was normal again 57.2 s after the broker came back.

  • Broker killed, run 3

    Back after 43.6 s, first publish 7.3 s later. 24,772 events waited and drained in 1.8 s at 13,959 a second; latency was normal again 24.4 s after the broker came back.

Audit log

Every committed journal becomes a leaf in a Merkle tree, and a witness outside the database signs its roots. How fast that runs as the log grows, how big a proof is, and whether tampering is caught.

JournalsSequenced a secondFull verificationProof sizeBytes a journal
100,00025,4622.8 s320–544 B405
500,00023,17213.9 s448–608 B404
1,000,00021,71831.5 s480–640 B404

Checking only what was added on top of a million: 1,000 journals in 33 ms, 10,000 journals in 228 ms, 100,000 journals in 2,236 ms. Under 200 payments a second, a journal waited 510 ms for the sequencer at the median and 1,026 ms at p99.

TamperingTrialsCaughtRight journal namedCaught without the witness
class 1, covered917917917917
class 1, above the last checkpoint83838383
class 2, covered9009009000
class 2, above the last checkpoint100000
class 3, covered887887887887
class 3, above the last checkpoint113113113113

Reconciliation

Settlement files with faults injected at known rates, over three seeds. With references, everything is found. Without them, matching on the amount guesses, and a car wash sells five prices.

Payments a dayFallbackMissing lineDuplicate lineWrong amountFee differenceSettles next dayDropped referenceFalse breaksNo reference: placed wrong
50unique100%100%100%100%100%100%00%
50oldest95.1%100%100%100%100%87%013%
500unique100%100%100%100%100%100%00%
500oldest63.9%100%100%100%100%51.3%049%
2,000unique100%100%100%100%100%100%00%
2,000oldest53.9%100%100%100%100%23.5%077%

Still to measure

These need a quiet machine and run at night. Their numbers will appear here as they come in.

  • Schema change

    Adding a column to 20 million entries under live payments, naive against expand and contract.

  • A long chaos campaign

    A few hundred runs instead of five, on the final code.