DEV Community

Cover image for Race Condition on Concurrent Stock Updates
lukman lukman
lukman lukman

Posted on

Race Condition on Concurrent Stock Updates

A stock update can be implemented with a sequence that looks reasonable when only one request is running:

read stock
check availability
decrease stock
save the new value
Enter fullscreen mode Exit fullscreen mode

Lab 05 focuses on what happens when multiple operations execute that sequence against the same state.

The important part of the experiment is not whether each request contains valid business logic in isolation. The test is whether the invariant still holds when those requests overlap.

The lab covers this from several angles:

  • an unsafe read/check/write flow;
  • deterministic lost-update tests;
  • an atomic conditional SQL update;
  • PostgreSQL row locking;
  • concurrent inventory attempts;
  • concurrent booking attempts protected by a database uniqueness constraint;
  • a separate Go memory data-race example.

The main experiment is the application/database race.

The Smallest Inventory Case

Start with one unit of stock:

stock = 1
Enter fullscreen mode Exit fullscreen mode

Two operations attempt to consume it.

The expected business result is straightforward:

successful operations = 1
remaining stock       = 0
Enter fullscreen mode Exit fullscreen mode

The unsafe implementation separates the decision into multiple steps.

Conceptually, the execution is:

READ
  ↓
CHECK
  ↓
CALCULATE
  ↓
WRITE
Enter fullscreen mode Exit fullscreen mode

With one caller, the sequence behaves as expected.

With two callers, those steps can interleave.

Two Operations Read the Same State

Consider this execution:

Request A                    Request B
---------                    ---------
READ stock = 1

                             READ stock = 1

CHECK stock > 0              CHECK stock > 0

newStock = 0                 newStock = 0

WRITE stock = 0

                             WRITE stock = 0
Enter fullscreen mode Exit fullscreen mode

Both operations observed the same original value.

Both checks passed.

Both calculated the same replacement value.

The final database value is:

stock = 0
Enter fullscreen mode Exit fullscreen mode

Looking only at the final stock value does not expose the full problem.

Both operations may have been treated as successful even though only one unit existed.

The broken invariant is therefore not simply:

stock must never be negative
Enter fullscreen mode Exit fullscreen mode

The test also needs to account for the number of successful decrements.

For one available item:

successful decrements <= 1
Enter fullscreen mode Exit fullscreen mode

The Lost Update

The unsafe flow calculates a new value outside the final write:

read stock
calculate stock - 1
write calculated value
Enter fullscreen mode Exit fullscreen mode

That creates a window where another operation can read the same state.

The important interleaving is:

A reads 1
B reads 1

A calculates 0
B calculates 0

A writes 0
B writes 0
Enter fullscreen mode Exit fullscreen mode

The second write is not based on the state produced by the first write.

It is based on the earlier value both callers observed.

That is the lost-update behavior exercised by the lab.

The repository uses deterministic concurrency coordination for these tests rather than depending only on timing or arbitrary sleeps. Barriers/start gates are used to force the relevant operations to overlap at the point required by the experiment.

That matters because a concurrency test that passes or fails only depending on scheduler luck is a weak proof.

Moving the Condition Into the Update

One implementation in the lab removes the separate read/check/write sequence.

The SQL statement is:

UPDATE inventory_products
SET stock = stock - 1
WHERE id = $1
  AND stock > 0
RETURNING stock;
Enter fullscreen mode Exit fullscreen mode

There are two important details in this statement.

First:

stock = stock - 1
Enter fullscreen mode Exit fullscreen mode

The application does not read a value, calculate a replacement, then send that replacement back.

Second:

WHERE stock > 0
Enter fullscreen mode Exit fullscreen mode

The availability condition is part of the statement that mutates the row.

The operation becomes:

attempt decrement
only when stock > 0
Enter fullscreen mode Exit fullscreen mode

instead of:

read
decide in application
write later
Enter fullscreen mode Exit fullscreen mode

This removes the application-level race window demonstrated by the unsafe implementation.

The Invariant Is Checked at the Write Boundary

The atomic version changes where the decision happens.

Unsafe model:

Application:
    read stock
    check stock
    calculate new stock

Database:
    write supplied value
Enter fullscreen mode Exit fullscreen mode

Atomic model:

Database:
    decrement stock
    only if stock > 0
Enter fullscreen mode Exit fullscreen mode

The difference is visible under contention.

The repository includes a concurrent inventory scenario with:

initial stock = 100
concurrent attempts = 500
Enter fullscreen mode Exit fullscreen mode

The useful assertion is not merely that all concurrent calls return.

The final state must still be consistent with the initial inventory.

The number of successful decrements cannot exceed the amount that was available.

PostgreSQL Row Locking

The lab also contains a PostgreSQL row-lock implementation and tests.

The relevant read uses FOR UPDATE:

SELECT stock
FROM inventory_products
WHERE id = $1
FOR UPDATE;
Enter fullscreen mode Exit fullscreen mode

The row lock changes the execution shape.

Instead of two transactions independently reading the same stock and later trying to update it, the critical read/check/update flow is coordinated around the same row.

A simplified execution is:

Transaction A
-------------
BEGIN

SELECT ... FOR UPDATE

check stock

update stock

COMMIT
Enter fullscreen mode Exit fullscreen mode

A concurrent transaction targeting the same locked row does not proceed through the same critical section independently while the first transaction still owns the lock.

The repository demonstrates this as another way to protect the same business invariant.

The lab does not use that result to claim that every concurrency problem should be solved with row locking. It is one implementation demonstrated against the stock-update problem.

Atomic Update and Row Lock Solve the Problem Differently

Both implementations target the same unsafe boundary, but the shape is different.

With the atomic update:

UPDATE ...
SET stock = stock - 1
WHERE stock > 0
RETURNING stock;
Enter fullscreen mode Exit fullscreen mode

the condition and mutation fit into one statement.

With row locking:

lock row
↓
read state
↓
make decision
↓
update
↓
commit
Enter fullscreen mode Exit fullscreen mode

the operation retains a multi-step decision while coordinating access to the row.

The lab therefore gives two concrete implementations rather than reducing the topic to "put everything in a transaction."

A transaction establishes a transaction boundary.

The concurrency behavior still depends on how the shared row is accessed inside that boundary.

Booking Uses a Different Invariant

The repository also applies the concurrency problem to booking.

The invariant is no longer a numeric stock value.

A booking is identified by a combination including:

branch_id
service_date
slot_time
Enter fullscreen mode Exit fullscreen mode

The schema protects that combination with a unique constraint:

UNIQUE(branch_id, service_date, slot_time)
Enter fullscreen mode Exit fullscreen mode

The important concurrency case is:

many requests
↓
same branch
same date
same slot
Enter fullscreen mode Exit fullscreen mode

An application can check whether the slot exists before inserting.

That check is useful for normal control flow, but it is not enough to enforce the invariant under concurrency.

Two requests can both observe:

slot is available
Enter fullscreen mode Exit fullscreen mode

before either insert is visible to the other operation.

The database constraint is the final storage boundary.

Concurrent Booking Test

The booking test exercises a single slot with:

500 concurrent attempts
Enter fullscreen mode Exit fullscreen mode

Only the valid database state should remain.

PostgreSQL uniqueness conflicts are mapped through SQLSTATE:

23505
Enter fullscreen mode Exit fullscreen mode

The conflict is treated as an expected concurrency outcome rather than as proof that the database is broken.

The database is enforcing the invariant that the application needs.

This is the same design principle as the inventory test, applied to a different state shape.

Inventory invariant:

successful decrements <= available stock
Enter fullscreen mode Exit fullscreen mode

Booking invariant:

one row for one unique branch/date/slot combination
Enter fullscreen mode Exit fullscreen mode

The mechanism is chosen around the invariant being protected.

Application Pre-Checks Do Not Replace Constraints

Consider the booking sequence:

SELECT booking
WHERE branch/date/slot = requested slot

if not found:
    INSERT booking
Enter fullscreen mode Exit fullscreen mode

Under concurrent execution:

Request A                       Request B
---------                       ---------
SELECT → not found

                                SELECT → not found

INSERT

                                INSERT
Enter fullscreen mode Exit fullscreen mode

Without storage enforcement, both requests reached the same application decision.

The unique constraint prevents the database from accepting both final rows for the same slot.

This is another example of why code that is correct sequentially can still be incomplete as concurrent business logic.

Memory Data Race vs Business Race

Lab 05 also keeps a Go memory data-race example.

That experiment is useful, but it is not the same failure as the inventory and booking cases.

A memory data race is concerned with unsynchronized memory access between goroutines.

The inventory and booking experiments focus on business invariants over shared application/database state.

Those two categories can overlap in some systems, but they should not be treated as synonyms.

The repository keeps the intentional memory race separated so the application/database concurrency tests can be reasoned about independently.

What the Tests Need to Prove

Concurrency tests need stronger assertions than:

no panic
Enter fullscreen mode Exit fullscreen mode

or:

all goroutines completed
Enter fullscreen mode Exit fullscreen mode

For inventory, useful assertions concern the invariant:

successful operations
remaining stock
initial stock
Enter fullscreen mode Exit fullscreen mode

For booking, the relevant assertion concerns how many rows survive for the unique slot.

The final repository revision also made the PostgreSQL lost-update reproduction deterministic rather than relying on an arbitrary timing window.

That is an important testing property.

The test should deliberately create the interleaving being investigated.

Commands

Lab 05 is a nested Go module.

The repository exposes dedicated Make targets including:

make lab-05-test
make lab-05-test-race
make lab-05-vet
make lab-05-fmt
make lab-05-integration
Enter fullscreen mode Exit fullscreen mode

The nested module boundary matters when running tests from the repository root: a plain root-level go test ./... does not implicitly replace the dedicated Lab 05 targets.

The README was updated to reflect that layout.

What This Lab Demonstrates

The unsafe inventory example starts from code that is locally understandable:

read
check
modify
write
Enter fullscreen mode Exit fullscreen mode

Concurrency changes the correctness requirement.

Another operation can run between those steps.

The fixes in the repository move the invariant to a boundary that can protect it:

atomic database update
Enter fullscreen mode Exit fullscreen mode

or:

row lock around the critical database flow
Enter fullscreen mode Exit fullscreen mode

For booking, the invariant is enforced through:

database uniqueness
Enter fullscreen mode Exit fullscreen mode

The broader result from the experiment is specific:

sequential correctness
does not prove
concurrent correctness
Enter fullscreen mode Exit fullscreen mode

The invariant has to survive the interleavings that the system actually permits.

Source

This article is based on an implementation from my Software Engineering Lab:

https://github.com/lukman-ss/software-engineering-lab/tree/main/labs/05-race-condition

Top comments (0)