The Optimistic Concurrency EF Core Gives You for One Column
A rowversion on the aliquot, a DbUpdateConcurrencyException instead of a
lost update, and a retry that does not stampede. The same 50-request hammer as Part 1, measured
against SQL Server, with no application locks held.
Part 1 ended with two instances of the
same LIMS, each holding its own lock for aliquot 42, and 1000 µL dispatched from a tube that
held 900. The lock was real. It guarded the wrong layer. The optimistic concurrency EF Core offers
fixes that with one column and one exception. Most teams add the column, then write a
catch that puts the lost update back.
The exception is the feature. The code that swallows it is the bug.
The Token Prevent Overselling Concurrency Bugs With One rowversion Column
The transfer service stays exactly as it was in Part 1: read, check, mutate, save. No semaphore, no keyed lock. The only change is in the model:
public class Aliquot
{
public int Id { get; set; }
public decimal AvailableVolumeMicroL { get; set; }
public AliquotStatus Status { get; set; }
public byte[] Version { get; set; } = default!;
}
// LimsDbContext.OnModelCreating
b.Entity<Aliquot>().Property(a => a.Version).IsRowVersion();
IsRowVersion() maps the column to SQL Server rowversion, which the
database bumps on every write, and marks it as a concurrency token. A token changes one thing:
the UPDATE that SaveChangesAsync sends.
The check moved into the WHERE clause. If another request wrote the row between our
read and our write, the version no longer matches, the UPDATE touches zero rows, and
EF Core throws DbUpdateConcurrencyException. The comparison runs inside the database,
so it does not matter how many instances, workers or cron jobs touch the row. That is the layer
Part 1 was missing.
Same hammer, 50 concurrent transfers of 500 µL from 900 µL, against SQL Server:
| Service | Succeeded | Assignments | Volume left |
|---|---|---|---|
| Part 1, no token | 25 | 25 | 400 (12,500 µL dispatched from 900) |
| rowversion token, no retry | 1 | 1 | 400 |
Twenty-five confirmations became one. Two requests ended in a concurrency exception, the other forty-seven read the updated row and were rejected on the business rule. No application lock was taken.
The Exception The EF Core Concurrency Exception Is the Feature
DbUpdateConcurrencyException is not an error to log and move on. It is the database
telling you that your decision was based on data that no longer exists. The only correct response
is to read again and decide again.
The common mistake is to catch it and call SaveChangesAsync a second time on the same
context. That can never work. The change tracker still holds the original Version it
read the first time, so the second UPDATE carries the same stale value in its
WHERE clause and touches zero rows again. Measured with 50 concurrent transfers of 10 µL, five attempts each:
The fix, shown here as a reload on the same context, is to refresh the entry so the tracker gets the current values and the current token, and then run the business rule again on what is really in the tube:
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single(); // the Aliquot that lost the race
// The first save failed because the tracker still held the Version from the first read
// Reload re-reads the row: current volume AND current Version, and both become the tracker's new "original" values
await entry.ReloadAsync(ct);
// Never assign aliquot.Version yourself. EF Core builds the next WHERE clause from the value Reload just fetched
var aliquot = (Aliquot)entry.Entity;
if (aliquot.AvailableVolumeMicroL < requiredVolumeMicroL)
return TransferResult.Rejected("Insufficient material.");
aliquot.AvailableVolumeMicroL -= requiredVolumeMicroL;
await db.SaveChangesAsync(ct); // WHERE Version = the reloaded one
}
The cleaner version is to drop the context and run the whole use case again. Either way the rule runs twice. What you never do is copy your in-memory values over the database values and save. That is the lost update from Part 1 wearing a try/catch.
The Retry A Retry That Does Not Stampede
Retrying is where optimistic concurrency quietly fails in production. Fifty requests race, one wins, forty-nine retry at the same instant, forty-eight lose again. We ran 50 concurrent transfers of 10 µL from 900 µL, all of them valid, with three retry policies and a cap of ten attempts:
| Retry policy | Succeeded of 50 | Gave up | Total attempts | p95 latency |
|---|---|---|---|---|
| Immediately | 28 | 22 | 337 | 51 ms |
| Fixed 50 ms delay | 36 | 14 | 225 | 566 ms |
| Decorrelated jitter, 20 ms base, 1 s cap | 50 | 0 | 157 | 838 ms |
Immediate retry is the stampede itself. A fixed delay is the same stampede, 50 ms later, because every loser wakes up at the same moment. Jitter is the only policy that spread the losers out far enough for all fifty valid transfers to land, and it did so with the fewest attempts.
// decorrelated jitter: sleep = min(cap, random(base, previous * 3))
delayMs = Math.Min(1000, Random.Shared.NextDouble() * (delayMs * 3 - 20) + 20);
await Task.Delay(TimeSpan.FromMilliseconds(delayMs), ct);
Two rules go with it. Cap the attempts, because a retry loop without a ceiling is a denial of service you wrote yourself. And when the cap is hit, tell the caller. "The aliquot changed, try again" is a correct answer. A silent oversell is not.
The Limit Where Optimistic Concurrency Breaks: the Hot Row
Optimistic concurrency assumes conflicts are rare. It does not fail when that assumption breaks. It gets expensive, then it starts rejecting valid work. We hammered one aliquot with N concurrent 1 µL transfers, jitter retry, ten attempts:
| Concurrent requests | Succeeded | Gave up | Attempts per success | p95 latency |
|---|---|---|---|---|
| 2 | 2 | 0 | 1.5 | 77 ms |
| 10 | 10 | 0 | 2.4 | 350 ms |
| 50 | 50 | 0 | 4.8 | 1,046 ms |
| 200 | 169 | 31 | 8.2 | 2,645 ms |
At ten concurrent writers the row costs 2.4 attempts per transfer. At two hundred it costs 8.2, and 31 technicians are told to try again while there is material in the tube. Nothing oversold, so the mechanism kept its promise. But a queue formed, and it formed in the retry loops of 200 requests instead of in the database, where a queue belongs.
The Rule One Column, One Exception, One Retry
This is the default for every balance in a shared database: aliquot volume, stock level, wallet, booking slot. It costs nothing until a conflict happens and it works across every instance that can reach the row.