Codemojex · C5.2 · a dive of The Economy and the Bank
The transactional wallet.
Balances live in Postgres and move only inside database transactions. The wallet is the one system where correctness outranks speed, and it buys that correctness with a row lock and a paired ledger row — not a single-writer process. Every mutation locks, checks, writes, and records, all or nothing.
Grounding — echo/…/codemojex/wallet.ex (debit/5, lock/1, txn!/5, buy_in/2, insert_buy_in/3) · schemas/player.ex (the players_non_negative CHECK) · the wallet + golden-economy stories.
FOR UPDATE, the balance covers the amount, the new balance is written, and the paired TXN row is inserted — then the transaction commits. Balance and ledger move together or not at all.Lock, check, write, ledger
wallet.ex · debit/5Every mutation is one Repo.transaction of four steps. The row lock serializes only same-player mutations, so the whole field is never funnelled through one writer process — the database does the work a single-writer GenServer used to, and scales with it.
defp debit(player, currency, amount, reason, ref) do
Repo.transaction(fn ->
case lock(player) do # 1. SELECT … FOR UPDATE
nil -> Repo.rollback(:no_player)
p ->
have = Map.get(p, currency)
if have < amount do
Repo.rollback(:insufficient) # 2. the non-negative check
else
update!(p, %{currency => have - amount}) # 3. write the balance
txn!(player, currency, -amount, reason, ref) # 4. the paired TXN row
have - amount
end
end
end)
end
defp lock(id), do: Repo.one(from p in Player, where: p.id == ^id, lock: "FOR UPDATE")
The wallet story pins the guard: a player with zero keys cannot guess in a paid room — the guess is refused for insufficient funds and the balance is still zero, never below. The players_non_negative CHECK is the database backstop if application logic ever slips:
defp guard(cs) do @balances |> Enum.reduce(cs, &validate_number(&2, &1, greater_than_or_equal_to: 0)) |> check_constraint(:keys, name: :players_non_negative, message: "balance cannot go negative") end
Alongside every write, txn!/5 inserts an append-only TXN row in the same transaction, so a statement is an ordered query (Codemojex.Ledger.history/2) and no balance ever moved without a paired record. The ledger gives correctness; the row lock gives concurrency.
The two-sided, exactly-once buy-in
wallet.ex · buy_in/2Wallet.buy_in/2 is the money headline of the Golden Room: one transaction that locks the games row and the player row, debits the entry fee, funds the pool, and books the membership through a partial unique index so a re-join cannot double-charge. Select a case below.
buy_in TXN inserts cleanly, so the count rises: the transaction debits the entry fee, funds the pool by the fee waterfall, books the house cut, and returns :member. Exactly one buy_in row now exists for this player and game.defp insert_buy_in(player, game, before) do
tid = EchoData.BrandedId.generate!("TXN")
{:ok, _row} =
%Transaction{}
|> Transaction.changeset(%{id: tid, player: player, currency: "keys", delta: 0, reason: "buy_in", ref: game})
|> Repo.insert(
on_conflict: :nothing,
conflict_target: {:unsafe_fragment, "(player, ref) WHERE reason = 'buy_in'"}
)
if buy_in_count(game) > before, do: :wrote, else: :suppressed
end
Exactly-once lives in the ledger, not in a returned struct: on_conflict: :nothing returns a loaded struct carrying the minted id even when the write was suppressed, so the caller compares the buy-in count before and after — the count rose iff a row was actually written. The golden-economy story pins it: a re-join debits keys once, moves the pool once, leaves exactly one buy_in TXN, and answers :already_member.
References
sources · relatedSources
- Repo.transactionthe all-or-nothing balance-and-ledger transaction
- Helland — Life Beyond Distributed Transactionsidempotent activities keyed by an id
Related
- BCS — the architecture lawone writer per book, the ledger law
- C5.1 — Three currenciesthe currencies this moves
- C5.3 — The bank, the pool, and the rakewhere the buy-in’s pool is settled