The ledger core takes no lock, and the protocol says whose job it is
Placing a hold is a read followed by a write: you ask what is available on an account, refuse if the amount does not fit, store the hold. Two callers doing that at the same moment can both read the same figure, both find room, and both write, and the account now reserves more than it holds. A ledger core cannot close that gap on its own, because it owns no records to lock. So you put the gap in the interface and name the side responsible for closing it.
That is the shape I settled on in ledger-core, a double-entry core for custodial balances. The core promises the arithmetic. The store promises that a check and the write it guards land as one step. Both halves are written down, and the second one is a method on a protocol rather than a paragraph in a README, which (as I will get to) is the difference between a contract and a wish.
The two steps#
Here is the whole of it, unedited:
def place(
self,
*,
hold_id: str,
account: Account,
amount: MoneyLike,
placed_at: datetime,
memo: str = "",
metadata: Mapping[str, str] | None = None,
) -> Hold:
"""Reserve funds. Refused if they are not there to reserve."""
with self._storage.atomic():
if self._storage.hold(hold_id) is not None:
raise DuplicateHold(f"hold {hold_id} is already placed")
hold = Hold(
hold_id=hold_id,
account=account,
amount=amount,
placed_at=placed_at,
memo=memo,
metadata=metadata or {},
)
available = self.available(account)
if hold.amount.amount > available:
raise InsufficientFunds(
f"hold {hold_id} reserves {amount}, "
f"{available} {account.currency} is available on {account.account_id}"
)
self._storage.put_hold(hold)
return holdTwo guarded writes are stacked in that method, not one. The duplicate check reads a hold by id, then writes a hold under that id. The funds check reads available(account) and then writes. Strip the with line and the method still reads as correct to a reviewer going top to bottom, and it is wrong in the only sense that matters in production.
Holds.spend has the same shape for a movement that was never reserved:
def spend(self, entry: Entry, *, account: Account) -> Entry:
"""Write a movement out of an account, refused if the funds are not free.
An entry answers to its own rules only, and balancing says nothing about
whether what it takes is still there once open holds are counted. This
asks that question first and appends only if the answer holds.
"""
with self._storage.atomic():
taken = -effect_on(entry, account)
available = self.available(account)
if taken > available:
raise InsufficientFunds(
f"entry {entry.entry_id} takes {taken} {account.currency} from "
f"{account.account_id}, {available} {account.currency} is available"
)
return self._journal.append(entry)The arithmetic the core actually owns#
What the core guarantees with no help from anyone is narrow, and worth stating precisely: people assume it covers more than it does.
An entry is checked for balance when it is built, so a one-sided movement never becomes an object. Journal.append asks whether the entry balances and nothing else: it has no opinion about whether the account can afford it. A balance is folded from postings rather than kept as a number, so there is no stored total for two writers to read and write back over each other, and that whole class of lost update does not exist here. available is balance less held, both derived.
None of that says anything about two spend calls racing on one account. Each one balances. Each one gets a truthful answer from available() at the moment it asks. The arithmetic is sound in both and the account still ends up overdrawn. Correct arithmetic over an unstable read is still a wrong answer, and no amount of invariant checking inside the core fixes it, because the instability sits in the store.
The seam has a name#
So the seam is declared:
@runtime_checkable
class Storage(Protocol):
def atomic(self) -> AbstractContextManager[None]:
"""One unit of writing: everything under the block, or none of it.
A guarded write is two steps — asking whether the funds are free or the
key already settled, and then writing — and a refusal in the second must
not leave the first behind. Writes made under the block land together;
leaving it by an exception undoes them. A block opened inside another
joins it rather than settling on its own, so a guarded write called from
inside a wider one widens to it.
"""The reason this is a protocol method and not prose is mechanical: you cannot implement Storage without writing atomic. A type checker raises it, and isinstance(InMemoryStorage(), Storage) is asserted in the test suite. Whoever adapts this core to their database gets forced to answer the question at the moment they are best placed to answer it, since they are the only person who knows what their store can promise.
An earlier shape of this package had no atomic at all. Storage declared append_entries,put_hold,put_snapshot and put_operation, and the README said the atomic check-then-write pair belonged to the backend. True words, and useless ones. The core performed the check and the core performed the write; the backend was handed two unrelated method calls with no indication that they belonged together. There was nothing for a transaction to attach to. The split was documented and unimplementable at the same time, which is the specific failure I want to name, because it reads like diligence. A division of responsibility that gives the other side no place to stand has not been divided. It has been dropped.
What the reference store does#
The in-memory store has no transaction of its own, so it builds one:
@contextmanager
def atomic(self) -> Iterator[None]:
"""Writes under this block land together, or not at all."""
with self._lock:
if self._depth:
self._depth += 1
try:
yield
finally:
self._depth -= 1
return
# records are values, so a shallow copy of each shelf is a whole oneThe outermost block keeps a copy of each shelf and puts it back if the block is left by an exception, and the RLock admits one writer at a time. Rollback is cheap for the same reason balances cannot drift: records are values, so copying the dicts copies everything that could change.
For a dict-backed double this is exactly right. As a mental model for production it misleads you in one direction: it serialises every writer in the process, not the writers touching one account. Which brings me to the part that actually bites.
A transaction is not the whole promise#
For a database-backed store,atomic is the transaction you already have, and that is usually where people stop. Not enough. The read inside the block is available(account): the account's balance folded from the journal, resumed from a snapshot, minus the sum of its open holds. Under read-committed isolation, two transactions can both read that pre-state, both insert their hold row, and both commit. No constraint is violated; a hold is just a row, and nothing in the schema knows it was meant to fit inside a figure that was true a moment ago. The block was atomic. It was never serialised against the other writer.
So the promise a backend owes is more specific than "wrap it in a transaction": the read the core makes inside the block and the writes that follow must not interleave with another writer on the same account. Two ordinary ways to get there: serialisable isolation with a retry on conflict, or an explicit lock on the account row taken before the read. Neither is exotic. What takes the work is noticing you need one, and that is what a declared method with a docstring, sitting in front of the implementer, buys you.
One thing you do not have to guard: the snapshot cache. A snapshot is only ever replaced by one that reaches further, and one claiming more entries than the journal holds is refused, so a stale snapshot costs you the replay of what it is missing and never a wrong answer. The guarded set is holds and entries. Effort spent locking the cache is effort not spent on the thing that can actually go wrong.
Blocks nest, and that is why capture is one step#
Holds.capture appends the settling entry and then writes the settled hold: two writes, and Journal.append opens a block of its own for the first of them. If that inner block committed independently, a refusal in the second half would leave the entry in the journal with the hold still open, and the ledger would claim the money both moved and is still reserved. The joining rule is what lets me describe capture as a single step rather than a sequence that is usually fine.
It works outward too. An application that wants "settle the hold and mark the payout row paid" in one commit opens its own transaction and calls capture inside it; the core's block widens to the caller's instead of closing early. Width belongs to whoever has the broader unit of work, and the core does not assume it is that party.
Prove the rollback, do not assert it#
The second half of a guarded write is the half nobody exercises, so the suite has a store you can tell to refuse:
class Refusing(InMemoryStorage):
"""A store that can be made to refuse one kind of record."""
def __init__(self) -> None:
super().__init__()
self.refuses: str | None = None
def put_hold(self, hold: Hold) -> None:
self._check("hold")
super().put_hold(hold)
def put_operation(self, operation: Operation) -> None:
self._check("operation")
super().put_operation(operation)
def _check(self, kind: str) -> None:
if self.refuses == kind:
raise RuntimeError(f"the store refused the {kind}")That is how you get to assert the property that matters, a store that refuses the hold leaves no entry behind, instead of hoping it holds. There is a tests/test_races.py next to it for the overlapping-call side of the same question.
What I would do differently#
Ship atomic in the first version. The release that documented the split without providing it was worse than one that said plainly "this core is single-writer, do not run two processes against it": an honest limitation is actionable, a contract the other side cannot fulfil is not.
Write the isolation requirement into the protocol in verifiable terms. "How wide that promise is belongs to the store" is true and leaves the sharp edge unnamed. "The read made inside the block and the writes that follow must not interleave with another writer on the same account" is something a backend author can check their isolation level against.
And I would probably be more careful about the reference store setting expectations. A test double stricter than production is how a race survives a green suite: the in-memory store's one-writer-at-a-time lock makes the concurrency tests pass for a reason no database will reproduce. The double proves the rollback semantics. It cannot prove the isolation, and the suite should say so where someone will read it.
A core that takes no lock is a reasonable core. A core that takes no lock and does not say so is a race condition with documentation.