Skip to content

Close the §4.6 currency-lock race: shared account-row locks across money-writing handlers (#162)#235

Merged
mforce merged 2 commits into
mainfrom
feat/162-currency-lock
Jul 27, 2026
Merged

Close the §4.6 currency-lock race: shared account-row locks across money-writing handlers (#162)#235
mforce merged 2 commits into
mainfrom
feat/162-currency-lock

Conversation

@mforce

@mforce mforce commented Jul 27, 2026

Copy link
Copy Markdown
Owner

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 SHARE on the account row, not an advisory lock or a dedicated lock row:

  • Money writes take shared locks — they never block each other, so write concurrency is untouched. The only path that pays is the admin-only currency change (FOR UPDATE), which is exactly the path that should pay.
  • The "hot row" cost is per-write multixact bookkeeping — noise at this deployment's write rate. If a future load test disagrees, the repository methods are the single swap point for pg_advisory_xact_lock_shared (call sites unchanged).
  • It locks the actual guarded resource, visible in the SQL — no hash-collision surface, no invisible cross-feature key space.
  • Deadlock analysis: money writers' shared locks are mutually compatible; the only exclusive taker (the settings handler) locks nothing but the account row — no cycle exists regardless of where the shared lock sits in each handler's lock order.

What changed

  • IAccountRepository.GetCurrentSharedLockedAsync / GetCurrentLockedAsync: raw SQL with the tenant WHERE inside — composing a locking clause with the global query filter would wrap it in a subquery over ALL accounts and lock every tenant's row.
  • Five handlers grow a transaction spanning currency read + insert (CreateExpense, CreateSalesOrder, CreateProduct, UpdateProduct, CreateInventoryItem); two already-transactional ones swap to the locked read (RecordPurchase, UpdateInventoryItem). UpdateProduct additionally 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 UPDATE first inside the transaction. Holding it means no writer is mid-flight and none can start, so one probe is authoritative — the second probe and its CurrencyLandedMidFlight error are deleted (no external references; the domain's Account.CurrencyLocked refusal is unchanged). Lock failures (40001/40P01) surface via the existing global PostgresException → 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 reads Account.DefaultCurrencyCode — it stamps order.TotalAmount.CurrencyCode off the order row it already holds FOR 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 via pg_blocking_pids (row-lock waits park on the holder's transactionid, so the naive pg_locks relation probe misses them) — no timing guesses.
    • Writer first → the settings handler parks, then refuses with Account.CurrencyLocked; farm stays USD, row stays USD.
    • Change first → one theory case per stamping handler (7): each parks, then stamps the NEW currency. Per-handler cases are the point: a handler missing its lock passes every functional suite. Mutant-verified — reverting CreateProduct's lock fails exactly the product-create case, 6/7 still green.
  • CurrencyLockSerializationTests kept untouched, as the issue instructs — executable evidence that SERIALIZABLE does not close this.
  • TDD: both race directions watched RED against the pre-lock code (settings change raced past the writer; expense stamped the stale currency).

Verification

mforce 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).
@mforce

mforce commented Jul 27, 2026

Copy link
Copy Markdown
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

# Finding (reviewer) Fix
1 The per-handler theory only proves the locked READ blocks; a handler whose lock evaporates on autocommit (locked read in its own transaction, insert separate) passes every case (codex #1, important) New writer-first test with real handlers on both sides: a raw FOR UPDATE fence parks the expense handler at its read and the settings change behind the same row; on release, PG's FIFO queue grants the writer first and the change must wait out the writer's whole transaction, then refuse. Empirically mutant-verified: the exact evaporating-lock mutant fails this test 5/5.
2 Blocking probe could false-positive on unrelated blockers (codex #2 ≈ pi #2) Probe now keys on the holder's backend pid via pg_blocking_pids(pid) @> ARRAY[holderPid]. Note pi's suggested fix (ILIKE '%accountId%') cannot work: the handlers' lock queries are parameterized, so the GUID never appears in pg_stat_activity.query.
3 Raw currency-change fixture didn't bump Version like the real mutation (codex #3) "Version" = "Version" + 1 added.
4 "Only the currency change conflicts" is too narrow — FOR SHARE also conflicts with plain account UPDATEs (FOR NO KEY UPDATE), so a rename save can briefly wait behind money writes (codex #4) Documented accurately in IAccountRepository; accepted behavior (rare, admin-only, transaction-length waits).
5 The discarded GetCurrentLockedAsync result + "tracked" doc wording could mislead (cavecrew #1's kernel, pi #1's kernel, Claude-agent advisory) Comments added at both sites: the call exists for the lock; EF identity resolution returns the SAME instance without refreshing values, so capturing the result changes nothing; Version's concurrency token (AccountConfiguration.cs:39) is what guards stale writes (→ DbUpdateConcurrencyException → global 409).

Rejected — with evidence

Verification after fixes

  • .NET: 779 green ×2 (426 integration; 14 currency-lock tests: 4 kept SERIALIZABLE-evidence + writer-first raw + writer-first real-handlers + 7-handler theory + change-first)
  • Mutant probes: dropping one handler's FOR SHARE fails exactly its theory case; the evaporating-lock mutant fails the new writer-first test 5/5
  • Build: 0 warnings / 0 errors

@mforce
mforce merged commit 82c2a0a into main Jul 27, 2026
7 checks passed
@mforce
mforce deleted the feat/162-currency-lock branch July 27, 2026 02:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Close the §4.6 currency-lock race properly (shared lock on the account row across money-writing handlers)

1 participant