Skip to main content

How to Prevent Overselling with a Conditional UPDATE in EF Core

· 6 min read
Jagdish Kumawat
Founder @ Dewiride

When two people buy the last item, the safe way to decide who gets it is to let the database decide, in one statement. Put the stock check in the UPDATE's WHERE, and read how many rows changed. That's all of it, and every round I ran gave one winner and four polite refusals.

Prerequisites​

Step 1: Write the Update​

src/Dewiride.Inventory.Api/Features/Orders/OrderEndpoints.cs (excerpt)
foreach (var line in lines)
{
// MySQL locks the row, checks the condition and changes the row in one
// statement. A second buyer waits here, then sees the new quantity.
var taken = await db.Products
.Where(p => p.Id == line.ProductId && !p.IsArchived && p.QuantityOnHand >= line.Quantity)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(p => p.QuantityOnHand, p => p.QuantityOnHand - line.Quantity)
.SetProperty(p => p.UpdatedAtUtc, now),
ct);

if (taken == 0)
{
// Leaving without a commit rolls back the stock already taken.
return await ExplainRefusalAsync(db, line, ct);
}
}

The foreach loop in a macOS-style code window, with ExecuteUpdateAsync whose Where includes QuantityOnHand at least the line's quantity, and a check for zero rows taken.

  • The Where has the rule in it: this product, not archived, and enough stock.
  • SetProperty subtracts in the database, using the column's current value, so it can't be stale.
  • taken is the row count. 1 means it worked, and 0 means it didn't match.

Step 2: See Why It's Safe​

MySQL locks the row, then checks the WHERE, then changes it, all in one statement. A second buyer's statement waits for that lock, and when it runs, it sees the new quantity. There's no gap between the check and the write for anyone to slip through.

In another session, I took the last unit in a transaction, and didn't commit for a while. An order sent in the meantime waited, and then matched nothing:

API log
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (6,033ms) [Parameters=[@line_Quantity='?' (DbType = Int32), @p='?' (DbType = DateTime), @line_ProductId='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
UPDATE `products` AS `p`
SET `QuantityOnHand` = `p`.`QuantityOnHand` - @line_Quantity,
`UpdatedAtUtc` = @p
WHERE ((`p`.`Id` = @line_ProductId) AND NOT (`p`.`IsArchived`)) AND (`p`.`QuantityOnHand` >= @line_Quantity)

Step 3: Run the Race​

Five orders for the last unit, at once:

Output
201
409
409
409
409
Output (the quantity, the sum of the movements, and the orders)
+----------------+----------------+--------------+
| QuantityOnHand | SumOfMovements | OrdersPlaced |
+----------------+----------------+--------------+
| 0 | 0 | 1 |
+----------------+----------------+--------------+

Five orders giving one 201 and four 409, with QuantityOnHand 0, SumOfMovements 0 and OrdersPlaced 1.

How to Test a Race Condition in a Web API from the Terminal has the command.

Step 4: Explain the Refusal​

0 rows doesn't say why, and there are three possible reasons. So look, once you already know you're refusing:

src/Dewiride.Inventory.Api/Features/Orders/OrderEndpoints.cs (excerpt)
private static async Task<ProblemHttpResult> ExplainRefusalAsync(
InventoryDbContext db,
PlaceOrderLine line,
CancellationToken cancellationToken)
{
var product = await db.Products
.Where(p => p.Id == line.ProductId)
.Select(p => new { p.IsArchived, p.QuantityOnHand })
.FirstOrDefaultAsync(cancellationToken);

if (product is null)
{
return TypedResults.Problem(
title: "The product doesn't exist.",
detail: $"There is no product with Id {line.ProductId}.",
statusCode: StatusCodes.Status400BadRequest);
}

return product.IsArchived
? TypedResults.Problem(
title: "The product isn't for sale.",
detail: $"Product {line.ProductId} is archived.",
statusCode: StatusCodes.Status409Conflict)
: TypedResults.Problem(
title: "There isn't enough stock.",
detail: $"Product {line.ProductId} has {product.QuantityOnHand} on hand, and the order asks for {line.Quantity}.",
statusCode: StatusCodes.Status409Conflict);
}
Response
HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
"title": "There isn't enough stock.",
"status": 409,
"detail": "Product 13 has 0 on hand, and the order asks for 1.",
"traceId": "00-110ee5beb526309ea2c360741ddd73f9-05f38dbeba82a661-00"
}

Step 5: Use More Than One Line Safely​

Three things matter when an order has several lines:

Common Mistakes​

MistakeWhat happensDo this instead
Checking in C# firstIt's a race againPut the check in the Where
Ignoring the row countEvery buyer thinks they wonRead it
Setting a value computed in C#It's stale by the time it's writtenUse p => p.QuantityOnHand - quantity
Dropping the CHECK constraintNothing stops a bug elsewhereKeep it as a backstop

Troubleshooting​

SymptomLikely causeFix
The row count is 0 for an update that changes nothingUseAffectedRowsLeave it at its default
Deadlock found with several linesLines in client orderSort them
A stale value afterwardsA tracked entityThe fix

Conclusion​

Put the rule in the Where of ExecuteUpdateAsync, read the row count, and let MySQL's row lock decide who wins. It's simple, and it held in every round I ran.

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.