← all topics Game Currency Wallet (Transfers + Analytics) · say it in this order 1 Problem 2 Entities + API 3 High-level design 4 Deep dives 5 Final + levels reset
SAY THIS SENTENCE FIRST

"Every movement of currency is a transfer in one append-only double-entry ledger, and the whole design is one tension: each movement must end in exactly one outcome, enforced by a guard fused into the debit inside one ACID transaction, and when the two accounts stop sharing a shard, that exactly-one-outcome promise has to survive as a saga through per-shard escrow fenced by a single local outcomes table."

STEP 1 OF 5

Understanding the Problem

4 min
💰 What are we building?
  • A game economy's wallet: players hold a currency balance and send it to each other.
  • Quests and shops mint and destroy currency, so the system owns the whole money supply, not just the P2P transfers.
  • The one sentence that organizes everything: every movement of currency, including minting and destroying it, is a transfer between accounts in one append-only double-entry ledger. Everything else is derived from it.
  • The economy gets attacked. A duplication exploit at 2am must be detected in seconds, and there must be a freeze lever.
  • GM refunds and bug cleanups exist. They are compensating entries, never edits to history.

Functional Requirements

  1. Players should be able to transfer currency to each other: exactly once, never overdrawing, and safely retryable.
  2. The economy should mint and destroy currency through quests and shops, inside budgets that someone owns.
  3. Players should be able to read balances and analytics: 24-hour flows and a 30-day balance series, with stated freshness.
  4. Corruption and exploits should be detected in seconds, and the economy can be frozen while money is put back.

Below the line (out of scope):

  • Real-money payments and payment processors. This ledger moves one in-game currency.
  • Item and inventory trading. Worth one sentence if asked: items would be another ledger, not rows in this one.
  • Fraud ML beyond velocity caps. The caps are the in-scope hook a fraud team would later feed.
  • Multi-region active-active. Explicit cut, defended in deep dive 6: one region of record plus a DR replica, nothing merged.

Non-Functional Requirements

Do the scale math first, because it decides what you are allowed to build
  • 100M transfers/day is about 1.2K/s average, 10-15K/s at peak. Reads run 20-50x writes.
  • About 200M ledger legs/day is roughly 20GB/day, TB-scale per year. Retention needs tiering; the write rate alone does not need exotic hardware.
  • At 1.2K/s, one well-tuned Postgres primary handles the writes. So say this out loud: this is a correctness problem first, and the streaming tier is bought for exactly one thing, catching corruption in seconds instead of overnight.
  1. No double-spend and no negative player balance, enforced inside the database, never by app-side check-then-act. Transfers are never approximate, and amounts are integers only.
  2. The slowest 1% of transfers still complete in under 150ms (p99) at peak.
  3. Analytics may lag by seconds and must say so: every derived read carries an asOf timestamp. The truth path and the read models share no fate.
  4. Corruption is detected in seconds and a freeze lever exists. The nightly full recompute is the backstop, not the front line.
DONE WHEN: you have said "this is a correctness problem first" out loud and the interviewer nods at the four functional requirements. That sentence licenses one Postgres primary now and the streaming tier later.
STEP 2 OF 5

The Set Up: Entities + API

4 min

Defining the Core Entities

  • Account holds a balance and a floor. Player accounts floor at 0, so overdraft is impossible. System accounts (quest faucets, shop sinks) floor at -budget, with a named owner. One column, one guard, two jobs.
  • Transfer is one requested movement, with a lifecycle (PENDING → COMPLETED or REJECTED) and a reversal_of field, so a refund is a new opposite transfer pointing at the original, never an edit.
  • Ledger entry (leg): every transfer writes two, minus on the sender and plus on the receiver, always summing to zero. Each leg records balance_after and a per-account sequence number, allocated inside the balance-moving transaction, so there are no mixed clocks.
  • Rollups are per-account, per-hour summaries computed FROM the ledger. Derived, rebuildable, never the truth.

API or System Interface

POST /v1/transfers        Idempotency-Key: key   // scoped (actor, key)
  { to, amount }          // sender = auth session, never the body
  → 201 COMPLETED | 202 PENDING   // 202 = cross-shard or queued; poll the GET
  → 422, body: { error: INSUFFICIENT_FUNDS | ACCOUNT_CLOSED | ACCOUNT_FROZEN | FAUCET_BUDGET }
  → 409, body: { error: KEY_REUSED }   // same key, different body
GET  /v1/transfers/{id}                         // got a 202? ask here until COMPLETED or REJECTED
GET  /v1/accounts/{id}/balance                  → { balance, asOf }
GET  /v1/accounts/{id}/flows?window=24h         → { inflow, outflow, windowStart, windowEnd, asOf }
GET  /v1/accounts/{id}/balance-series?days=30   → { points: [{hourUtc, closingBalance}], asOf }
  • Same key with the same body replays the stored outcome, so retries are exact, not accidental. The same key with a different body is a 409, because silently picking one body would hide a client bug that involves money.
  • 202 PENDING is a first-class state shared by exactly two paths: the cross-shard saga (deep dive 2) and the hot-sender queue (deep dive 5). Both resolve through the same GET.
  • Validation before any money moves: amount > 0, to ≠ actor, amount ≤ cap. The gateway rate-limits mutations; analytics reads are authorized to the account owner or a service principal.
  • Every derived read carries asOf. Freshness is part of the contract, not a footnote.
DONE WHEN: the four nouns are on the board and you have said "202 PENDING is a first-class state shared by the saga and the hot-sender queue". The transaction itself waits for deep dive 1.
STEP 3 OF 5

High-Level Design

10 min, end to end, no dives yet

1) Players can transfer currency to each other

wallet-hi1
  • The sender is the authenticated session, never the request body, so no client can debit an account it does not own by construction.
  • The whole movement is one ACID transaction in a sharded SQL ledger: idempotency anchor insert, two single-row locks in explicit sorted order, one fused guard, two legs, status, one commit. Deep dive 1 opens it statement by statement.
  • Rejection also commits. A failed guard marks the transfer row REJECTED and commits, because nothing changed and the anchor row must survive for retries to find.
  • Reads split by purpose: spending reads hit the primary, display reads hit replicas and are marked stale.

2) The economy mints and destroys currency, inside budgets

wallet-hi2
  • A quest reward is an ordinary transfer from a faucet account to the player. A shop fee is a transfer into a sink. There is no special mint path to secure separately.
  • The faucet's floor is its negative budget, so the same fused guard that stops player overdrafts caps minting. A compromised payout service hits the budget wall (422 FAUCET_BUDGET) instead of printing forever.
  • Budgets deplete monotonically, so replenishment is on the board, not implied: the named owner tops the faucet up on a stated cadence with an approved transfer from a treasury account, which is itself budgeted.
  • circulation = -SUM(faucet balances) - SUM(sink balances). Faucet balances are negative, so the number comes out positive. The ledger is the inflation dashboard; no separate metrics system exists to drift from it.
  • "A quest pays every player 100 gold" is N ordinary transfers sent in batches against faucet sub-accounts that were split ahead of time, so the burst never hammers one row. Afterward SUM(delta) across all legs still reads exactly zero.

3) Players can read balances and analytics, with honest freshness

wallet-hi3
  • CDC copies committed ledger rows into Kafka, keyed by account, so one account's history stays in order. It tails the commit log, never a dual write: if it did not commit, it does not stream.
  • The rollup keeps a running total per (account, hour) in its own crash-safe state and flushes the bucket's current TOTAL. Never an increment. On a crash, state and read position rewind together, the same events replay, and identical totals overwrite themselves.
  • An hour's closing balance is the balance_after of the last entry at or before hour end, where the entry's hour comes from a timestamp written in the same statement as the leg, and "last" means the per-account sequence number, one timeline, no mixed clocks. Idle hours carry the previous value forward, and late data corrects only the hours it touches. Nothing compounds.
  • The 24h answer is 23 finished hours, plus the current partial hour, plus the slice of the oldest hour still inside the window. The partial hour is served from processor state; during a restart it falls back to the last flushed hour, and asOf admits it.

4) Exploits are detected in seconds, and the economy can freeze

wallet-hi4
  • The integrity checker reads the same stream analytics reads, which is the whole trick: detection inherits analytics' seconds-level freshness for free.
  • The stream is grouped by account, so a transfer's two legs land in two different groups. The checker regroups legs by transfer id and alarms if a leg stays unmatched past a few seconds.
  • Three invariants: all legs sum to zero; each leg's balance_after equals the previous balance plus its own delta; MIN(player balance) ≥ 0. The third is not redundant: a bug that consistently double-spends the same coins passes the first two, and only the third catches it.
  • Velocity caps (how fast and how much one account may send) catch abuse the balance guard cannot see: stolen accounts draining slowly, bots.
  • Escalation ladder: freeze the account, then freeze the economy at the gateway (transfers 503, reads fine), then page a human. Cleanup is bulk reversals, compensating entries, never edits.
  • The nightly full recompute over the hot database plus the cold tier is the backstop, not the front line.
DONE WHEN: the interviewer picks a box to open. Let them steer from here.
  • If they just nod and wait: "the riskiest box is the transfer transaction, shall I open it?"
  • Running behind: protect dives 1 and 3.
STEP 4 OF 5

Potential Deep Dives

~20 min, interviewer steers
TRIGGER: "walk me through a transfer" / "how do you prevent double-spend"

1) How is a transfer exactly-once, with no double-spend?

  • The enemy is the check-then-act window: read a balance, decide in the app, write a new balance. Two concurrent spends of the same coins both pass the read. The second enemy is the retry: a timeout plus a re-send must not move money twice.
Bad Solution: app-side read-modify-write
  • Read the balance, check "≥ amount" in code, write the new balance. Two concurrent 100-gold spends of a 100-gold balance both read 100, both pass, both write. The account goes negative or money duplicates, and a retry after a timeout sends the money twice.
  • Everything else in this design exists to not be this.
Good Solution: fuse the guard into the debit statement
  • UPDATE accounts SET balance = balance - amt WHERE id = A AND balance - amt ≥ 0. The check and the act are one statement under the row lock, so no window exists. 0 rows affected means rejected.
  • Alone it is not enough: concurrent A→B and B→A still deadlock on lock order, a retry has no idempotency anchor to find, and a rejected transfer leaves no recorded outcome.
Great Solution: one transaction that anchors, locks in sorted order, guards, and commits either outcome
BEGIN
  INSERT transfers(actor, key, A, B, amt, PENDING)   -- dup (actor,key): replay stored outcome / 409
  SELECT status FROM accounts WHERE id = min(A,B) FOR UPDATE   -- two single-row locks,
  SELECT status FROM accounts WHERE id = max(A,B) FOR UPDATE   -- explicit sorted order
      -- missing | CLOSED | FROZEN -> SET status = REJECTED; COMMIT; 422
  UPDATE accounts SET balance = balance - amt
    WHERE id = A AND status = ACTIVE AND balance - amt ≥ floor
      -- players: floor 0. faucets: floor -budget. One guard stops
      -- overdraft AND runaway minting. 0 rows -> REJECTED; COMMIT; 422
  UPDATE accounts SET balance = balance + amt WHERE id = B AND status = ACTIVE
      -- rows-affected checked here too: no crediting a closed account
  INSERT 2 ledger legs (each with balance_after, per-account seq)
  SET status = COMPLETED
COMMIT
accounts  (id PK, status, balance, floor)
transfers (id, actor, idem_key, from, to, amount, status, reversal_of,
           UNIQUE(actor, idem_key))              -- the anchor
legs      (transfer_id, account_id, delta, balance_after, seq,
           UNIQUE(account_id, seq))              -- chain-check; seq allocated in the balance-moving txn
  • Say why, in three sentences: rejection commits, never rolls back, because nothing changed and the anchor row must survive for retries. Two explicit lock statements, because ORDER BY plus FOR UPDATE does not guarantee acquisition order (that is planner behavior, not a contract). The guard is fused into the debit statement, so no check-then-act window exists.
  • Probe: concurrent A→B and B→A where A's id sorts after B's? Both transactions issue the same two lock statements, B then A. The second waits at its first lock. No cycle, no deadlock, independent of money direction.
  • Probe: debit matches 0 rows? Nothing rolls back, because nothing changed. The same transaction marks the row REJECTED and commits. A same-key retry waits at the UNIQUE(actor, idem_key) index until that commit lands, then reads the one recorded outcome. There is no moment where the record does not exist.
TRIGGER: "what if the two accounts are on different shards"

2) What if the two accounts live on different shards?

  • At 10-15K/s peak with TB-scale retention, the ledger shards by account id, and a transfer between two shards can no longer be one local transaction.
  • Say the restraint first: do not build this until cross-shard pairs are measured as common. Same-shard placement of trading partners may defer it forever.
Bad Solution: a distributed transaction (2PC) across ordinary shards
  • Two-phase commit makes every cross-shard transfer hold locks on two machines while waiting on a coordinator. A coordinator crash leaves rows locked in doubt, and p99 inherits the slowest shard on every transfer.
  • You built the hardest machinery in databases to avoid measuring whether trading partners could just share a shard.
Good Solution: buy Spanner-class SQL
  • One transaction even across shards, so deep dive 1's shape survives. Honest and often right.
  • Costs: slower commits, vendor lock-in, and the SELECT FOR UPDATE semantics are not identical to Postgres (CockroachDB adds retryable aborts), so "the code is unchanged" slightly overclaims.
Great Solution: a saga through per-shard escrow, fenced by one local outcomes table
  • Three small local transactions instead of one big one. Every shard has a parking account, ESCROW. Step 1: the sender's money moves to her shard's escrow. Step 2: the receiver is paid from his shard's escrow. Step 3: the two escrows settle later, in batches. One escrow only fills up, the other only drains; step 3 settles the difference.
  • Six legs total, and each step balances to zero on its own, so SUM(delta) = 0 holds at every commit boundary, and in-flight traffic never trips the conservation check.
  • The race: the payment worker and the timeout-cancel job can both act on the same transfer. The fence is an outcomes table on the receiver's shard that accepts one row per transfer id. The credit's claim is CREDITED, the cancel job's claim is CANCELLED, a frozen receiver gets REFUSED and no legs. Whoever's insert lands does its job; the other sees the existing row and gives up. Nothing ever needs to atomically observe another shard's row.
  • The receiver-shard transaction, written out: BEGIN; UPDATE accounts SET balance = balance + amt WHERE id = B AND status = ACTIVE. Rowcount 1 → INSERT outcomes(transfer_id, CREDITED) plus the ledger legs. Rowcount 0 → INSERT outcomes(transfer_id, REFUSED), no legs. COMMIT. Entirely local; the sweeper refunds escrow on REFUSED exactly as on timeout, and touches the sender shard only after winning that local race.
  • The client gets 202 PENDING and polls GET /transfers/{id}. One honest sentence: between step 2 and the lazy escrow settlement, the receiver can already spend the credit while the poll still says PENDING. Money moved, status lags by design; say it out loud rather than let the interviewer find it.
  • Bookkeeping: exclude escrow from circulation or in-flight money looks minted. And the outcomes table grows by one row per cross-shard transfer forever unless trimmed, so give it a TTL once rows age past the sweeper's horizon.
TRIGGER: "2am dupe exploit" / "how fast do you detect corruption"

3) A dupe exploit starts at 2am. When do you find out?

  • The domain sets the clock: a duplication exploit spreads on Discord in hours, so the market is dead before a nightly job wakes up. Detection latency is a product requirement, not an ops nicety.
Bad Solution: nightly reconciliation is the only check
  • The exploit runs from 2am until the batch job notices, up to a full day later. Twenty hours of duplicated gold moving through the market, then a cleanup that has to unwind thousands of downstream trades.
Good Solution: the right invariants, still on a batch clock
  • Check all three: legs sum to zero, balance_after chains hold per account, and MIN(player balance) ≥ 0. The third is the one naive designs miss, and it is not redundant: a guard-bypass bug that double-spends consistently passes the first two.
  • Better failure coverage, same detection latency. The 2am problem stands.
Great Solution: a continuous checker on the stream that already exists, holding a freeze lever
  • Point the CDC stream at a second consumer. The checker regroups legs by transfer id (the stream is keyed by account, so a transfer's legs arrive in different partitions), pairs them, and alarms if a leg stays unmatched past a few seconds. Chain checks ride the same flow.
  • Velocity caps catch what invariants cannot: a stolen account draining slowly is arithmetically consistent, and only rate limits on how fast and how much one account sends will flag it.
  • Escalation is mechanical, because 2am has no time for judgment: freeze the account, then freeze the economy at the gateway, transfers 503 while reads stay fine, then page a human with evidence. Cleanup is bulk reversals: compensating entries with reversal_of set, history never edited.
  • The nightly full recompute over hot plus cold tiers stays, as the backstop that catches anything the stream lied about.
TRIGGER: "analytics" / "how fresh are the numbers" / "rollup crash"

4) The rollup crashes 3 seconds after a checkpoint. What do the numbers say?

  • The freshest number on the panel is the one an incident commander stares at, so it is exactly the number that must never double-count. Crash-and-replay is the normal case for a stream processor, not the edge case.
Bad Solution: increment counters as events arrive
  • INCR into a cache or table per event. The increment is a side effect outside the processor's checkpointed state, so a crash rewinds the read position but not the counter, and the replayed events add again. The 24h flows number silently inflates on every recovery.
Good Solution: dedupe by entry id, key by account
  • Events keyed by account keep one account's history in order, and dropping duplicates by entry id makes redelivery harmless.
  • Not enough alone: if the flush is still "add this batch's subtotal", a crash between flush and checkpoint re-adds the subtotal. Dedupe protects against redelivery, not against replayed aggregation.
Great Solution: SET-from-state everywhere, and a series defined so errors cannot compound
  • The processor keeps the running total per (account, hour) in crash-safe state, and every flush writes the bucket's current TOTAL. Nothing in the derived path ever says "add"; everything says "the total is X". Crash 3 seconds after a checkpoint: state and read position rewind together, the same events rebuild the same totals, identical values overwrite themselves. At most seconds stale, never double-counted.
  • The series point is a lookup, not a recurrence: closing balance of hour h = balance_after of the last entry at or before the end of h, ordered by the per-account sequence. Idle hours carry forward; late data corrects only its own hours; an error cannot propagate into the next point.
  • The 24h window is decomposed honestly: 23 finished hours + the partial hour from processor state + the still-inside slice of the oldest hour. During a processor restart, the partial hour falls back to the last flushed bucket, and asOf tells the caller: stream availability degrades freshness, never correctness.
TRIGGER: "marketplace account" / "one account gets all the traffic"

5) One marketplace account is in half of all transfers

  • Three different hot-account cases hide in this question, and the fused guard is what makes only one of them hard: credits have no guard, debits do.
Bad Solution: one fix for all three, split every hot account into sub-accounts
  • Splitting works only where no guard runs. For a hot SENDER, the no-overdraft guard needs the total across all sub-accounts atomically, which is exactly what splitting destroyed. Payouts start bouncing off one empty sub-account while the others hold plenty.
Good Solution: split by direction, because only one direction carries the guard
  • Hot receiver: split into sub-accounts, credits pick one at random, reads sum them. Credits have no guard, so nothing else changes, and freezing is still one status check on the parent.
  • Hot faucet: already solved by design. The guard is the budget floor, and the budget was split across the sub-faucets from the start.
  • The hot guarded sender remains, and it is the real case: the marketplace paying thousands of sellers from one balance.
Great Solution: a single-writer queue for the hot sender, with the batch's lock order fixed
  • Give that one account a queue: one Kafka partition, one consumer. The consumer batches N payouts into a single transaction; debits apply in queue order, each checked against the guard individually, and payouts that no longer fit are marked REJECTED in the same commit. Queued clients see 202 PENDING, the same contract as the saga.
  • Never two writers: before the normal synchronous path touches any account, it checks the account's queue-mode flag while holding that account's row lock, so the regime cannot flip mid-write.
  • The lock-order trap hiding in the obvious version: locking the hot account first and then the receivers "in sorted order" violates the global rule whenever a receiver sorts before the hot account. A concurrent transfer INTO the marketplace locks R then waits on H, while the batch holds H and wants R: deadlock (a liveness and p99 problem, not corruption). The fix is folded in: collect the batch first, then lock ALL touched rows, the hot account plus its N receivers, in one global sorted sequence before applying anything, guard included.
TRIGGER: "close an account with money in it" / "who can refund" / "crash leaves PENDING rows?"
TRIGGER: "region goes down" / "Kafka dies" / "how long do you keep the ledger"

6) Lifecycle, failure, regions, retention (rapid fire)

  • Closing an account with money in it: the remainder transfers to a system escheat account, so conservation holds right through closure.
  • Reversals: a refund is a NEW transfer in the opposite direction, reversal_of pointing at the original, never an edit. It runs only under a dedicated service identity with two-person approval and its own audit log, because it is the one path allowed to move money its caller does not own, which makes it the most abuse-sensitive surface in the system.
  • Two different PENDINGs: the transfer row's internal PENDING is born and finished inside one transaction, so nobody outside ever observes it. The API's 202 PENDING is the cross-shard or queued state, resolved by polling. The sweeper's whole job is saga intent rows that waited too long; nothing else needs sweeping.
  • Primary down: reject transfers immediately. Never queue money you cannot commit. Balances stay readable from replicas, marked stale.
  • Kafka down: CDC pauses at the database's own commit log, so nothing is lost; the real limit is WAL disk headroom, so the alert is on space left. Worst case, every derived store rebuilds from the ledger.
  • Multi-region: one region owns all writes; a second keeps an always-slightly-behind replica for disaster recovery. True region loss costs at most seconds, reconciled from the replica ledger. Nothing is ever merged.
  • Retention: 90 days in the fast database, everything forever in cheap cold storage. The ledger gets point-in-time backups, restore to any minute. Analytics stores are derived and need no backup.
  • Numbers watched: transfer p99 and success rate, rejections by reason, analytics freshness p99 under 5s, consumer lag, reconciliation mismatches = 0, replication lag, hot-queue depth.
  • Simplest-design honesty: at 1.2K/s the right first system is one Postgres primary plus a one-minute SQL rollup. The streaming tier was bought by one requirement, and name which: the checker's seconds-level detection, not analytics.
DONE WHEN: every dive the interviewer opened ended on its Great card, and you volunteered the residual honesty yourself: the PENDING status lag during settlement, the outcomes-table TTL, the faucet replenishment cadence.
FLASHCARDS

The five hardest probes

show all (interview mode)
Concurrent A→B and B→A, A's id sorts after B's?
Both transactions issue the same two lock statements, B then A. Second one waits at its first lock. No cycle, no deadlock, independent of money direction.
Debit matches 0 rows?
Nothing rolls back, because nothing changed. The same transaction marks the transfer row REJECTED and commits. A retry with the same key waits at the database's uniqueness check until that commit lands, then reads the one recorded outcome. There is no moment where the record does not exist.
Quest pays every player 100 gold: what does SUM(delta) read after?
Exactly zero. Faucet shards go 100·N more negative within budget, players +100 each, every transfer's legs sum to zero. The event's mint total IS the faucet shards' aggregate delta: the inflation dashboard updating itself.
Rollup crashes 3s after checkpoint: what does /flows return?
The processor's memory and its reading position rewind together to the last checkpoint. It re-reads the same events, rebuilds the same running totals, and writes those totals again, overwriting identical values. Nothing in the derived path ever says "add"; everything says "the total is X." So the number is at most seconds stale, and never double-counted.
Write me the receiver-shard saga transaction.
BEGIN; UPDATE accounts SET balance = balance + amt WHERE id = B AND status = ACTIVE. Rowcount 1 → INSERT outcomes(transfer_id, CREDITED) + ledger legs. Rowcount 0 (receiver frozen or closed mid-saga) → INSERT outcomes(transfer_id, REFUSED), no legs. COMMIT. Entirely local. CREDITED, REFUSED, and the sweeper's CANCELLED are mutually exclusive on the primary key; the sweeper refunds escrow on REFUSED exactly as on timeout, and touches the sender shard only after winning that local race.
STEP 5 OF 5

Final Design + What is Expected at Each Level

close strong

Final Design

wallet-arch
  • One sentence first: every movement of currency, minting and destroying included, is a transfer between accounts in one append-only double-entry ledger, and everything else is derived from it.
  • The money path is one guarded ACID transaction: idempotency anchor, two single-row locks in explicit sorted order, the fused floor guard, two legs with balance_after and sequence, and a commit for either outcome. The floor column makes players (0) and faucets (-budget) the same mechanism.
  • CDC feeds one stream to two consumers: idempotent SET-from-state rollups for analytics, and the continuous integrity checker holding the freeze lever. The dashed arrow back to the gateway is the argument the whole streaming tier was bought for.
  • Cross-shard transfers and the hot-sender queue share the 202 PENDING contract and stay deferred until measurement demands them. The saga's fence is a single-shard outcomes table, never a distributed transaction, and the hot batch locks all touched rows in one global sorted sequence.
  • Behind the stream: nightly recompute over hot plus cold tiers, a DR replica region that never merges, and point-in-time backups for the one store that is the truth.

What is Expected at Each Level

  • Mid-level candidates are expected to produce double-entry with two legs summing to zero, a guard that lives in the database rather than the app, idempotency keys on the money endpoint, and some reconciliation story.
  • Senior candidates are expected to make exactly-once precise: two single-row SELECT FOR UPDATE statements in explicit sorted order (and why ORDER BY + FOR UPDATE is not that), rejection that commits its own anchor, idempotency scoped to (actor, key) with a 409 on body mismatch, CDC instead of dual writes, and SET-from-state rollups defended with the crash-3-seconds-after-checkpoint trace.
  • Staff candidates are expected to open with the scale math that says one primary suffices and then name what the streaming tier was actually bought for; to unify overdraft and minting under one floor guard; to argue why MIN(player balance) ≥ 0 is not redundant; to fence the saga with a local outcomes table and write the receiver-shard transaction on request; to split the hot-account question into three cases and catch the batch lock-order deadlock in their own design; and to volunteer the residual honesty themselves: the PENDING status lag during settlement, the outcomes-table TTL, and the faucet replenishment cadence, before the interviewer finds any of them.
DONE WHEN: you have said the one-sentence summary over the diagram and can point at which staff behaviors you actually hit this run.