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
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
Two operations attempt to consume it.
The expected business result is straightforward:
successful operations = 1
remaining stock = 0
The unsafe implementation separates the decision into multiple steps.
Conceptually, the execution is:
READ
↓
CHECK
↓
CALCULATE
↓
WRITE
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
Both operations observed the same original value.
Both checks passed.
Both calculated the same replacement value.
The final database value is:
stock = 0
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
The test also needs to account for the number of successful decrements.
For one available item:
successful decrements <= 1
The Lost Update
The unsafe flow calculates a new value outside the final write:
read stock
calculate stock - 1
write calculated value
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
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;
There are two important details in this statement.
First:
stock = stock - 1
The application does not read a value, calculate a replacement, then send that replacement back.
Second:
WHERE stock > 0
The availability condition is part of the statement that mutates the row.
The operation becomes:
attempt decrement
only when stock > 0
instead of:
read
decide in application
write later
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
Atomic model:
Database:
decrement stock
only if stock > 0
The difference is visible under contention.
The repository includes a concurrent inventory scenario with:
initial stock = 100
concurrent attempts = 500
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;
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
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;
the condition and mutation fit into one statement.
With row locking:
lock row
↓
read state
↓
make decision
↓
update
↓
commit
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
The schema protects that combination with a unique constraint:
UNIQUE(branch_id, service_date, slot_time)
The important concurrency case is:
many requests
↓
same branch
same date
same slot
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
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
Only the valid database state should remain.
PostgreSQL uniqueness conflicts are mapped through SQLSTATE:
23505
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
Booking invariant:
one row for one unique branch/date/slot combination
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
Under concurrent execution:
Request A Request B
--------- ---------
SELECT → not found
SELECT → not found
INSERT
INSERT
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
or:
all goroutines completed
For inventory, useful assertions concern the invariant:
successful operations
remaining stock
initial stock
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
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
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
or:
row lock around the critical database flow
For booking, the invariant is enforced through:
database uniqueness
The broader result from the experiment is specific:
sequential correctness
does not prove
concurrent correctness
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)