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.

UPDATE [Aliquots] SET [AvailableVolumeMicroL] = @p0 OUTPUT INSERTED.[Version] WHERE [Id] = @p1 AND [Version] = @p2;

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.

Two application instances read aliquot 42 with its version and both send an UPDATE guarded by that version; the first write wins and gets the new version back, the second touches zero rows, raises DbUpdateConcurrencyException and retries after a reload.
Both instances read version A3 and send an UPDATE guarded by it. The first write wins and bumps the version. The second touches zero rows, EF Core throws, the request reloads and decides again.

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:

No Reload, retry SaveChanges on the same context: conflicted 21, recovered 0, wasted attempts 105 Reload the entry, re-apply the rule, then save: succeeded 49 of 50, gave up after 10 attempts 1, volume 410

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
2201.577 ms
101002.4350 ms
505004.81,046 ms
200169318.22,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 signal: watch attempts per success on one row. Below about two, optimistic is doing its job for free. Past three, in this test, you have a hot row. Part 3 moves that row to pessimistic locking and lets SQL Server hold the queue.

The Rule One Column, One Exception, One Retry

Put a rowversion on every row whose balance two requests can change, let DbUpdateConcurrencyException reach a retry that reloads and decides again, and give that retry jitter and a cap. Measure attempts per success. When one row climbs past three, the line this test drew, stop retrying and move the queue into the database.

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.

← Back to the series overview


More posts
← All posts  ·  RSS © 2026 DotNET Leet