Skip to main content

Fixed: False "DbUpdateConcurrencyException" on Stock with a Concurrency Token in EF Core

· 5 min read
Jagdish Kumawat
Founder @ Dewiride

I had a Version concurrency token on products, so I tried it against overselling. It worked, and it also turned away customers: with 100 in stock and 5 simultaneous orders, 3 got 409 Conflict. They weren't conflicting over stock at all. They'd just happened to read the same version.

What I Was Doing​

My .NET C# Web API uses EF Core 10.0.14. Products have an int Version marked as a concurrency token, and selling one looked like this:

An endpoint
g.MapPost("/sell-version/{id:int}", async (int id, InventoryDbContext db, CancellationToken cancellationToken) =>
{
var product = await db.Products.FirstAsync(p => p.Id == id, cancellationToken);

product.QuantityOnHand -= 1;
product.Version += 1;

try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException)
{
return Results.Problem(title: "Someone else changed this product.", statusCode: 409);
}

return Results.Ok(new { product.QuantityOnHand });
});

g.MapPost("/sell-version-raw/{id:int}", async (int id, InventoryDbContext db, CancellationToken cancellationToken) =>
{
var product = await db.Products.FirstAsync(p => p.Id == id, cancellationToken);

product.QuantityOnHand -= 1;
product.Version += 1;

await db.SaveChangesAsync(cancellationToken);

return Results.Ok(new { product.QuantityOnHand });
});

The product had 100 on hand, and 5 sales came in at once:

Output (before)
+----------------+---------+
| QuantityOnHand | Version |
+----------------+---------+
| 100 | 0 |
+----------------+---------+

What I Expected​

5 sales, and 95 left. The token is there to catch people editing the same product.

What I Got​

Output (status codes, and how many of each)
2 200
3 409
Output (the product afterwards)
+----------------+---------+
| QuantityOnHand | Version |
+----------------+---------+
| 98 | 2 |
+----------------+---------+

The 409 is my catch. Without it, the same requests answer 500, and the log has the exception's own message:

API log
Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See https://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimist

Five requests giving two 200 and three 409, with QuantityOnHand 98 and Version 2.

3 sales refused, with 98 in stock.

What I Checked​

What a conflict means here. The EF Core page explains that with a token, EF Core adds it to the UPDATE's WHERE. If the row's value has changed since it was read, no row matches, and it throws. So any two requests that read the same version conflict, whatever they meant to do.

Why I saw 2 sales and not 1. The token only conflicts for requests whose reads overlap, so the count depends on timing.

Why It Happens​

A concurrency token asks "has anything changed since I read this?" Selling needs a narrower question: "is there enough stock?" For a person editing a product's name, the first question is the right one. For 5 buyers on a product with 100 in stock, the answer to the first is yes, and the answer to the second is also yes.

The Fix​

Leave the token where it belongs, on edits. Change stock with a conditional UPDATE, which doesn't touch the token:

An endpoint
g.MapPost("/sell-conditional/{id:int}", async (int id, InventoryDbContext db, CancellationToken cancellationToken) =>
{
var taken = await db.Products
.Where(p => p.Id == id && p.QuantityOnHand >= 1)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.QuantityOnHand, p => p.QuantityOnHand - 1), cancellationToken);

return taken == 1
? Results.Ok()
: Results.Problem(title: "There isn't enough stock.", statusCode: 409);
});

How to Prevent Overselling with a Conditional UPDATE in EF Core has the whole pattern.

After the Fix​

Output (status codes, and how many of each)
5 200
Output (the product afterwards)
+----------------+---------+
| QuantityOnHand | Version |
+----------------+---------+
| 93 | 2 |
+----------------+---------+

Five requests all answering 200, with QuantityOnHand 93 and Version still 2.

5 sales, from 98 to 93, and Version hasn't moved, so nobody editing the product gets a false conflict either.

Common Mistakes​

MistakeWhat happensDo this instead
Retrying on DbUpdateConcurrencyException in a loopIt works, slowly, and busy products suffer mostUse a conditional UPDATE
Dropping the token altogetherTwo edits overwrite each otherKeep it for edits
Bumping Version on every stock changeEditors get a conflict on every saleLeave it out of stock changes

Troubleshooting​

SymptomLikely causeFix
409 on a popular product onlyMore overlapping readsThis fix
A 500 for this exception on a real editNothing catches itCatch DbUpdateConcurrencyException, and answer 409
Stock goes negative with a conditional UPDATEThe WHERE is missing the checkAdd QuantityOnHand >= quantity

Conclusion​

A concurrency token treats any overlap as a conflict, so it refuses sales that were fine. Use it for edits by people, and use a conditional UPDATE for quantities.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.