The Inventory That Oversold When Two Requests Read One Stock
Both checkouts read one unit remaining at the same moment and both decremented it, so the store sold a product it did not have, and the reconciliation found…
It ended at the incident. It started at the placed. Every hand-off in between did exactly what it was told, and this is the one that lied.
I have hit this enough times that I now check for it before I check for anything clever.
The read-modify-write race has a single weak property, and here it is: the window is milliseconds, so it almost never happens, and the almost never is the untested, and the untested is the production's discovery, and the discovery is the customer's.
This was a WooCommerce store with a low-stock variant, and every inventory check that reads then writes without a lock behaves the same way.
Why both requests succeeded
The checkout read the stock, and the stock was the one, and the one was the available, and the available was the proceed, and the proceed was the decrement, and the decrement was the write, and the write was the zero, and the zero was the order's confirmation.
The second request read the stock at the same moment, and the moment was the before the first write, and the before was the one, and the one was the available, and the available was the proceed, and the proceed was the decrement, and the decrement was the negative one, and the negative was the oversold, and the oversold was the two orders for one unit.
The race is the read and the write's separation, and the separation is the window, and the window is the milliseconds, and the milliseconds is the simultaneous, and the simultaneous is the both seeing the one, and the seeing is the check's failure, and the failure is the check's staleness, and the staleness is the incident, because the check was true when it ran and false when it mattered.
Why the check did not prevent it
The check was the if, and the if was the stock greater than zero, and the greater was the true, and the true was the proceed, and the proceed was the race, and the race was the check's insufficiency, because the check is the read, and the read is the moment, and the moment is the stale, and the stale is the write's contradiction.
The check is the necessary and the insufficient, and the insufficient is the atomicity, and the atomicity is the read and the write as one, and the one is the lock, and the lock is the serialization, and the serialization is the second request's wait, and the wait is the re-read, and the re-read is the zero, and the zero is the rejection, and the rejection is the fix, and the fix is the atomic operation.
This is the same race as the Shopify draft order that committed inventory it never paid for, and the shared property is the one worth fixing.
The fixes
Made the decrement conditional in one statement. The update became the decrement where the stock is greater than zero, and the where is the condition, and the condition is the atomic, and the atomic is the fix, and the fix is the one statement, and the statement is the database's guarantee, and the guarantee is the affected rows, and the rows is the zero for the loser, and the loser is the rejection, and the rejection is the prevention.
Checked the affected row count. The update's result is the success's proof, and the proof is the one row, and the one row is the winner, and the zero rows is the loser, and the loser is the out of stock, and the out of stock is the message, and the message is the fix, because the update's result is the only reliable signal.
Used SELECT FOR UPDATE where the logic is complex. The row lock is the serialization, and the serialization is the second request's wait, and the wait is the re-read, and the re-read is the current, and the current is the correct check, and the check is the fix, and the fix is the transaction, and the transaction is the discipline.
Reconciled the inventory daily. The stock is compared against the orders, and the compared is the drift, and the drift is the oversold, and the oversold is the alert, and the alert is the detection, and the detection is the fix, and the fix is the schedule, and the schedule is the tripwire, because the race is rare and the rare needs the automated check.
Showed the stock as approximate at low counts. The display is the at low stock rather than the exact number, and the rather is the expectation, and the expectation is the oversold's softening, and the softening is the fix, and the fix is the honesty, because the exact count invites the race and the approximate sets the customer's expectation.
The rule
A stock check that reads then writes is a race, so two concurrent checkouts can both see the last unit and both take it. Make the decrement a single conditional update, check the affected row count, use row locks where the logic is complex, and reconcile inventory daily.
Two customers checked out the last unit within milliseconds of each other and both were told the order was confirmed, because each request read the stock before the other wrote. That is the whole pattern, and it is the part worth remembering.