recomputeState() already locks the transaction row (SELECT ... FOR UPDATE) before it derives and writes the aggregate state, so two recomputes cannot race. But the explicit checkout transitions, lockTransaction(), placeTransaction(), unlockTransaction(), and the terminal stamp of cancelTransaction(), did not: each reloaded the order, checked its state, and wrote, with no row lock and no compare-and-set.
Two transitions that land at once can therefore both read a stale state and clobber each other. Today those transitions come only from serialized workflow steps, so it is latent, but any consumer that drives them concurrently (a settling payment and a cancellation arriving together, for example) would hit it.
This extracts the row-lock pattern into a withRowLock() helper: open a database transaction, lock the row with SELECT ... FOR UPDATE, reload from that snapshot, run the transition, then commit when the lock leaves scope or roll back if it throws. Every transition is routed through it, so all transitions on one order serialize on its row: the second waits for the first to commit, then reads the state it left. This gives the order state machine the same compare-and-set guarantee the kessai payment state machine already has.
Issue fork yoyaku-3611850
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #3
mably commentedOpened merge request !45 against 1.x. Every order state transition now serializes on the transaction row (SELECT ... FOR UPDATE via a withRowLock helper), so a settling payment and a cancellation landing together can no longer both act on a stale read. Foundational for the payment-driven order lock lifecycle coming next.
Comment #5
mably commented