Idempotency — Two Clicks, One Charge
Preserve balances when a transfer fails halfway: design principles
In one line
You tie the debit, the credit, and the transfer record together atomically, and reproduce a concurrent balance race.
Why this was needed
The process died after the money was taken from the sending account and before it was added to the receiving account. The retry had no transfer id, so it took the money out again. Conversely, if only the transfer record is committed first, the retry judges it complete and the deposit is lost forever. Even if you compute amounts exactly with integers, the result is wrong if the transaction boundary is wrong.
How it works
accounts and transfers sit in the same SQLite file. It validates a valid amount and distinct accounts, and inside BEGIN IMMEDIATE it checks whether this is a resend and checks the balance. After the debit there is a fault injection point, and the deposit and the transfer record are committed all at once. The same id with the same content is a no-op duplicate, and different content is a conflict. At the end, it runs two transfers for which the balance is short at the same time and checks that only one succeeds.
BEGIN IMMEDIATE → 중복/잔액 검사 → 차감 → 장애 지점 → 입금+기록 → COMMIT
Worksheet: read the contract and predict the failure
What follows is not an answer sheet for memorizing the implementation, but a step-by-step code review. Each changed fragment deliberately breaks the contract. Note that normal cases may still pass after the change. Before running, predict which input, exception, or state you would have to observe to reveal the difference, and after implementing, compare that prediction with the result.
1. Create the ledger tables
init_db(path) idempotently creates accounts(id TEXT PRIMARY KEY,balance INTEGER NOT NULL) and transfers(id TEXT PRIMARY KEY,source TEXT NOT NULL,target TEXT NOT NULL,amount INTEGER NOT NULL).
Basis for the judgment: put the uniqueness in the DB so that the same transfer id is not committed twice.
The wrong changed fragment to review:
CREATE TABLE transfers
Compare it with the public contract of the function that contains this fragment. If a single success case cannot tell the difference, choose as the observation target an input that should be rejected or the state left after a failure.
2. Create an account only once
add_account(path, account_id, amount) allows only an int of 0 or more (not bool) and creates the account with INSERT. An account that already exists is rejected with sqlite3.IntegrityError, and the balance is preserved.
Basis for the judgment: keep an initialization UPSERT from resetting the real balance to the initial value.
The wrong changed fragment to review:
INSERT OR REPLACE INTO accounts VALUES
Compare it with the public contract of the function that contains this fragment. If a single success case cannot tell the difference, choose as the observation target an input that should be rejected or the state left after a failure.
3. Tell a missing account from 0 won
balance(path, account_id) returns the account balance, and raises KeyError if it does not exist.
Basis for the judgment: if you turn "missing" into 0, a transfer can proceed to a wrong account.
The wrong changed fragment to review:
return 0
Compare it with the public contract of the function that contains this fragment. If a single success case cannot tell the difference, choose as the observation target an input that should be rejected or the state left after a failure.
4. Validate the transfer input
validate_transfer(source,target,amount) returns None if source!=target and amount is a positive int (not bool), and otherwise raises ValueError.
Basis for the judgment: reject a transfer to the same account and a negative transfer beforehand.
The wrong changed fragment to review:
Compare it with the public contract of the function that contains this fragment. If a single success case cannot tell the difference, choose as the observation target an input that should be rejected or the state left after a failure.
5. Tie three writes into one transaction
transfer(path,tx_id,source,target,amount,fault=lambda:None) returns False for the same id and content, and raises ValueError for an id conflict, a missing account, or an insufficient balance. A new transfer atomically runs debit → fault() → credit → insert into transfers, and returns True.
Basis for the judgment: raise an exception on purpose after the debit and check that both the balances and the transfer record are back to their original state.
The wrong changed fragment to review:
db.commit()
fault()
Compare it with the public contract of the function that contains this fragment. If a single success case cannot tell the difference, choose as the observation target an input that should be rejected or the state left after a failure.
6. Read the transfer history deterministically
history(path) returns transfers as a list of (id,source,target,amount) tuples in ascending id order.
Basis for the judgment: state ORDER BY explicitly so that you do not confuse input order with lookup order.
The wrong changed fragment to review:
ORDER BY id DESC"
Compare it with the public contract of the function that contains this fragment. If a single success case cannot tell the difference, choose as the observation target an input that should be rejected or the state left after a failure.
7. Check the conserved amount
total_balance(path) is the sum of accounts.balance. If there are no accounts, it is 0.
Basis for the judgment: verify separately the return value of each request and the conservation of the total amount.
The wrong changed fragment to review:
MAX(balance)
Compare it with the public contract of the function that contains this fragment. If a single success case cannot tell the difference, choose as the observation target an input that should be rejected or the state left after a failure.
8. Concurrent transfers do not exceed the balance
compete(path,source,target,amount) runs transfer with the same amount in two threads using different ids race-a and race-b. It returns the list of results where only ValueError is converted to False. If the balance covers only one transfer, there is exactly one success.
Basis for the judgment: if you check the balance beforehand outside the transaction, two requests can see the same balance and both succeed.
The wrong changed fragment to review:
["race-a","race-a"]
Compare it with the public contract of the function that contains this fragment. If a single success case cannot tell the difference, choose as the observation target an input that should be rejected or the state left after a failure.
What it looks like in the field
This is not a system that replaces the accounting, audit, and legal requirements of a real financial ledger. It does not handle currencies, decimal units, fees, or multiple currencies, and amounts are positive integers in the smallest unit. A call to an external payment system is not included in this DB transaction, so it needs a separate design.
What you will do in the next lab
The eight steps connect into one runnable deliverable. Create the ledger tables → create an account only once → tell a missing account from 0 won → validate the transfer input → tie three writes into one transaction → read the transfer history deterministically → check the conserved amount → concurrent transfers do not exceed the balance.
Each step checks not the fact that a function or file exists but the actual return values, exceptions, and state changes. After you see the answer, deliberately change a boundary comparison or the cleanup code and check which test fails. Explain why the earlier tests are kept in the next step too, and write down one operational condition that this lab does not guarantee.