Two Tellers Read the Same Balance: Concurrent Withdrawals and Limits
Goal
You make a negative balance reproducible from withdrawals that overlap on the same account, and close the gap by merging the check and the update into one statement. You apply the daily cumulative limit in the same way, attach a bounded retry only to lock contention, and close with an invariant check and an evidence report.
Why it matters
A structure that reads the balance to check and then deducts separately is fine forever as long as requests come in one at a time. The moment requests overlap on the same account, both are approved on the basis of the same balance, both deductions are reflected, and the balance goes negative. The ledger and the balance do not diverge from each other at that point, so a closing check that looks only at "ledger total == balance" lets this incident pass. If you try to reproduce it with load, you end up relying on probability. This lab makes a deterministic reproduction by lining up the overlapping point instead of load. Forty requests take under a second, and the same numbers come out every time. The grader does not trust your statements. It cross-checks the values in the record files you leave against the real DB one by one. The grader does not cause the race again — because if reproduction is probabilistic, grading wobbles too.
Steps
- Create and run /root/limit/make_bank.py to put 6 accounts and 6 opening ledger lines into /root/limit/bank.db.
- Build the read-modify-write path in /root/limit/race_naive.py, run it overlapping on the A-RACE account with 2 workers x 20 rounds, and leave the result in /root/limit/race_naive.json.
- Build a conditional update path in /root/limit/transfer.py, run it on the A-SAFE account under the same conditions, and leave the result in /root/limit/race_safe.json.
- Experience a write lock directly with the A-FEE account and leave the difference between busy_timeout 0 and a generous value in /root/limit/lock_probe.json.
- Apply a daily cumulative limit of 300,000 won to the A-DAILY account inside the update statement and leave the result in /root/limit/daily_limit.json.
- Put requests into the A-RETRY account with 3 workers x 10 rounds, and leave the bounded retry policy applied only to lock contention and its result in /root/limit/retry_policy.json.
- Apply three invariants to the 6 accounts and leave the list of violations in /root/limit/invariant.json.
- Pull the numbers so far from the ledger and organize them into four sections in /root/limit/limit_report.md.
Notes
- Gather all outputs under /root/limit. Use only a single business date, 2026-09-17.
- Making the overlap: if you wait twice per round, for "both arrived" and "both read," they read the same balance. Creating one file each and counting the files is enough.
- Number of changed rows: changes() in SQLite, and the cursor's rowcount in Python sqlite3.
- To type BEGIN yourself, you must open with sqlite3.connect(..., isolation_level=None).
- Resetting state: sqlite3 /root/limit/bank.db "UPDATE account SET balance=150000 WHERE acct_id='A-RACE';"
- Common mistakes: judging approval with the balance you read, keeping the cumulative limit in a separate cell, applying retry even to business judgments (insufficient balance), and keeping the check to the ledger total alone.
Build the account snapshot
Create and run /root/limit/make_bank.py to put three tables, account, ledger, and attempt, and 6 accounts and 6 opening ledger lines into /root/limit/bank.db.
The accounts are A-RACE 150000/100000, A-SAFE 150000/200000, A-DAILY 1000000/300000, A-RETRY 300000/500000, A-FEE 80000/50000, and A-POOL 1950000/5000000 (balance/daily limit). Set the path of the opening lines to 'OPEN' and biz_date to 2026-09-17. The later steps must not touch these lines so that regrading gives the same answer.
Reproduce a negative balance with two overlapping requests
Create /root/limit/race_naive.py, run it overlapping on the A-RACE account with 2 workers x 20 rounds x 10,000 won, and leave the result in /root/limit/race_naive.json.
Keep the order as it is: read the balance, judge with the value read, and then update with balance = balance - amount. If you make both workers wait until both have read in every round, they see the same balance. In the record, put the number of approvals/rejections, the approved total, and the final balance, and also leave one line per request in the attempt table.
Put the check and the update in one statement
Create /root/limit/transfer.py, run it on the A-SAFE account under the same conditions as step 2, and leave the result in /root/limit/race_safe.json.
Attach the balance condition to the WHERE of the UPDATE, and judge approval by the number of changed rows. Use the balance you read only for the record (seen_balance). The evidence of this step is how many requests looked sufficient when read yet were rejected.
Experience a write lock and busy_timeout directly
On the A-FEE account, while one side holds the lock with BEGIN IMMEDIATE, try the same update with busy_timeout 0 and with a generous value, and leave it in /root/limit/lock_probe.json.
The holding side subtracts 1000 won and the waiting side 2000 won, and write path as 'lock'. If busy_timeout is 0, it fails immediately, and if it is generous, it sleeps until the lock is released and then succeeds. Also leave the blocked attempt in the attempt table with decision='busy' — without a record, you cannot know what happened.
Put the daily cumulative limit inside the same statement
Put 2 workers x 10 rounds x 50,000 won into the A-DAILY account, judge the daily limit of 300,000 won inside the update statement, and leave the result in /root/limit/daily_limit.json.
If you keep the cumulative amount in a separate cell, the limit lies from the moment that cell and the ledger diverge. Put in the UPDATE's condition a subquery that counts that day's withdrawal total from the ledger. Leave the rejection reasons distinguishing insufficient balance from limit exceeded.
Write retry as policy and keep to its limit
Put 3 workers x 10 rounds x 20,000 won into the A-RETRY account, and leave the bounded retry policy applied only to lock contention and its result in /root/limit/retry_policy.json.
If you set busy_timeout to 0, contention surfaces as an exception, so catch only that and try again. Insufficient balance gives the same answer no matter how many times you try again, so you must not retry it. Leave in attempt.attempts how many times each request was tried, and there must be no attempts beyond the policy's limit.
Check six accounts against three invariants
Apply balance_matches_ledger, no_negative_balance, and daily_limit_respected to the 6 accounts and leave the list of violations in /root/limit/invariant.json.
If you look at only the first invariant, the incident of step 2 passes — because everything approved was also written in the ledger. For each violation, write the account, which invariant it is, the observed value (observed), and the reference value (limit). The grader recomputes the same thing from the DB and compares the list.
Close with an evidence report
In /root/limit/limit_report.md, put four sections, 'What happened', 'How it was reproduced', 'How it was blocked', and 'Remaining risks and operating rules', and write them with numbers pulled from the ledger.
If you copy numbers by hand, they will be right once and wrong from the next run. Use a script that reads the DB to build the report. The what-happened section needs the final balance and the amount that went out in excess, the reproduction section the number of requests run overlapping, the blocked section the number approved by the conditional update, and the last section the amount that went out up to the day's limit and the total attempts including retries.