fix(concurrency): make the account application decision one-shot from REQUESTED - #61
Conversation
… REQUESTED updateStatus guarded its conditional UPDATE on the status it had just loaded instead of the fixed starting status. That only caught the interleaving where two callers read the same old value; when the calls serialise, the second one loads what the first wrote, its guard matches, and it overwrites the decision with no error. A REJECTED application could therefore be re-decided as ACCEPTED — and the ACCEPTED branch of the endpoint opens a bank account, so the overwrite is not recoverable. Guard on REQUESTED, matching the sibling transitions in DoobieBusinessStatusQueries (AccountAccessRequest guards on INITIATED, the challenge CAS on successful_c=false). Reword the zero-row failure, which now also covers "a decision was already recorded", and reuse the same constant for the initial status set at creation. This is what made the M3 race scenario intermittently red: it failed only when the two threads happened to serialise with REJECTED landing first. Add M3b, which reproduces the same defect deterministically with two sequential calls.
|
|
Follow-up measurement, recorded for the archive. The claim in the PR description that this defect is what made M3 intermittently red was reasoning, not measurement — M3 itself passed locally throughout. Here are the numbers. Harness: a temporary probe suite (not committed) running the M3 race repeatedly with real threads (
Both 200-iteration runs ended with finalStatus split roughly ACCEPTED 100 / REJECTED 100, so the race is genuinely happening and either side can win — there was simply never a run where both won. Two things follow. The old code was already correct on the path where both threads read The staggered runs reproduce the CI signature exactly: Reproduction status, stated precisely: the defect itself reproduces deterministically and is covered by the committed |


What
MappedAccountApplicationProvider.updateStatusguarded its conditional UPDATE on the status it had just loaded rather than on the fixed starting status:That only catches the interleaving where two callers read the same old value. When the calls serialise, the second one loads what the first wrote, its guard matches, and it overwrites the decision with no error.
So a REJECTED application could be re-decided as ACCEPTED. The ACCEPTED branch of
PUT /banks/BANK_ID/account-applications/ACCOUNT_APPLICATION_IDopens a bank account, so that overwrite is not recoverable.Change
Guard on
REQUESTED, matching the sibling transitions inDoobieBusinessStatusQueries—AccountAccessRequestguards onINITIATED, the challenge CAS onsuccessful_c = false. The zero-row failure message is reworded because it now also covers "a decision was already recorded", and the initial status set at creation reuses the same constant.Behaviour change: a second decision on an application that has left REQUESTED now fails instead of silently overwriting. Previously only ACCEPTED was terminal (via an in-memory check); REJECTED was not.
Why now
This is what made the
ConcurrentBusinessStatusRaceTestM3 scenario intermittently red — most visibly on the first attempt of run 30860104286, which passed on re-run. M3 only failed when its two threads happened to serialise with REJECTED landing first; when they interleave, both read REQUESTED and the old guard did catch the loser. The reverse serialisation was caught by the in-memory ACCEPTED check, so roughly half of the serialised runs went red.M3breproduces the same defect deterministically with two ordinary sequential calls — no threads, no barrier, no timing dependency. It fails on the parent commit and passes here.Scope
M2andM4were checked and need no change: both already guard on a fixed starting state.M1(theFOR UPDATElock inlockTransactionRequestbeing a no-op outside a request scope) is a separate, already-documented issue and is untouched.Verification
M3bred before the production change (accepted=Full(...ACCEPTED),finalStatus=ACCEPTED), green after../run_tests_parallel.sh, 4 shards).