Close the §4.6 currency-lock race: shared account-row locks across money-writing handlers (#162)#235
Merged
Merged
Conversation
added 2 commits
July 26, 2026 18:21
…162) Every handler that stamps Account.DefaultCurrencyCode onto a new row now takes FOR SHARE on the account row inside the same transaction as its insert, and the currency change takes FOR UPDATE — the two genuinely serialize under READ COMMITTED, in both directions. SERIALIZABLE could not deliver this (CurrencyLockSerializationTests, kept as evidence): SSI only sees conflicts among all-serializable transactions. - IAccountRepository grows GetCurrentSharedLockedAsync / GetCurrentLockedAsync; the locking clause lives inside the raw SQL with an explicit tenant WHERE, because composing FOR SHARE with the global query filter would wrap it in a subquery over ALL accounts and lock every tenant's row. - CreateExpense, CreateSalesOrder, CreateProduct, UpdateProduct, CreateInventoryItem grow a transaction spanning their currency read and insert; RecordPurchase and UpdateInventoryItem swap their in-transaction read for the shared-locked one. Shared locks never block each other, so money writes stay concurrent; only the admin-only currency change pays. - UpdateFarmSettingsHandler takes the exclusive lock first inside its transaction; holding it makes a single probe authoritative, so the second probe and its mid-flight error are gone. Lock-level failures (40P01/40001) surface through the existing global 409 mapping. - RecordPaymentHandler is deliberately not in the protocol: it stamps the ORDER's snapshotted currency off a row it already holds FOR UPDATE, and an existing order refuses the currency change outright. - CurrencyLockRaceTests: both interleavings driven against the REAL handlers with blocking observed via pg_blocking_pids (no timing guesses); one theory case per stamping handler, because a handler missing its lock passes every functional suite (mutant-verified: dropping product-create's lock fails exactly that case).
- Writer-first race test with REAL handlers on both sides: the fence parks the expense handler at its read AND the settings change behind the same row; on release the writer's whole transaction must complete before the change refuses. Pins the transaction boundary itself — the evaporating- lock mutant (locked read in its own transaction, insert separate) passed every prior case; this test kills it 5/5 (codex). - The blocking probe keys on the holder's backend pid via pg_blocking_pids instead of grepping query text — a parameterized account id never appears in pg_stat_activity, and pid-keying is immune to parallel classes (pi's concern, different fix; its ILIKE-the-guid suggestion cannot match). - The raw currency-change fixture bumps Version like the real mutation (codex). - Comments: the settings handler explains the deliberately-discarded GetCurrentLockedAsync result (identity map returns the same instance; Version's concurrency token guards staleness — both cavecrew and pi tripped there); IAccountRepository documents that FOR SHARE also makes plain account UPDATEs wait (codex) and that the locked read never refreshes an already-tracked instance (Claude-agent advisory).
Owner
Author
Review round outcome (codex + pi + 2 Claude agents)12 findings across 4 reviewers → 5 accepted (fixed in df97f1e), 7 rejected with evidence. The Claude review agent returned READY with zero blocking findings after independently verifying the six hard questions (probe visibility after the exclusive lock, codebase-wide deadlock orderings, missed stamping paths, rollback/audit semantics, raw-SQL scoping, test isolation). Accepted → fixed
Rejected — with evidence
Verification after fixes
|
This was referenced Jul 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #162 — the follow-up PR #159 deferred: the §4.6 currency lock now actually serializes against every money write.
Design decision (the issue's open point)
FOR SHAREon the account row, not an advisory lock or a dedicated lock row:FOR UPDATE), which is exactly the path that should pay.pg_advisory_xact_lock_shared(call sites unchanged).What changed
IAccountRepository.GetCurrentSharedLockedAsync/GetCurrentLockedAsync: raw SQL with the tenantWHEREinside — composing a locking clause with the global query filter would wrap it in a subquery over ALL accounts and lock every tenant's row.CreateExpense,CreateSalesOrder,CreateProduct,UpdateProduct,CreateInventoryItem); two already-transactional ones swap to the locked read (RecordPurchase,UpdateInventoryItem).UpdateProductadditionally fetches the grade mapping before mutating the tracked entity, so no rollback path leaves a dirty entity for the request's later idempotency-record save to flush.UpdateFarmSettingsHandler:FOR UPDATEfirst inside the transaction. Holding it means no writer is mid-flight and none can start, so one probe is authoritative — the second probe and itsCurrencyLandedMidFlighterror are deleted (no external references; the domain'sAccount.CurrencyLockedrefusal is unchanged). Lock failures (40001/40P01) surface via the existing globalPostgresException→ 409 mapping — nothing new needed.RecordPurchase's no-explicit-cost branch deliberately keeps the item's default cost without a lock: that currency was snapshotted at item creation, not read fresh off the account.Deliberately NOT in the protocol
RecordPaymentHandler(the issue's checklist lists it): it never readsAccount.DefaultCurrencyCode— it stampsorder.TotalAmount.CurrencyCodeoff the order row it already holdsFOR UPDATE, and any existing order already refuses the currency change outright. Locking the account row there would guard nothing.Tests
CurrencyLockRaceTests(new): both interleavings driven against the real handlers, with one raw transaction holding the opposing lock. Blocking is observed viapg_blocking_pids(row-lock waits park on the holder's transactionid, so the naive pg_locks relation probe misses them) — no timing guesses.Account.CurrencyLocked; farm stays USD, row stays USD.CreateProduct's lock fails exactly theproduct-createcase, 6/7 still green.CurrencyLockSerializationTestskept untouched, as the issue instructs — executable evidence that SERIALIZABLE does not close this.Verification