-
Notifications
You must be signed in to change notification settings - Fork 5.9k
Description
#Background
We have a site that has been up and running for about 11 years now. Currently running with v4.8.3
We are a cell phone retailer with extremely heavy order cycles when high-demand device inventory is released. We often experience sub-second consecutive order placements. These periods almost always cause inventory issues with oversold and/or mis-stated stock quantities because of this race condition described below. It causes the need for us to true up inventory via a product import in a maintenance window. This issue is not remedied by Redis distributed cache or any other infrastructure changes. It is a necessary code update to resolve.
Please review the attached and maybe we can get it as a PR into and up and coming 4.9.x release.
We have reported this on several occasions under our support agreement and each should have illustrations of the findings below, most recently:
- Request #17345 Product Quantity History
- Request #17349 Stock Quantity History Errors
Race Condition Analysis
// ProductService.cs:1832 - THE PROBLEM
product.StockQuantity += quantityToChange; // Read-Modify-Write without locking
await UpdateProductAsync(product); // Last write wins - LOST UPDATE!Quick Win: Implementing Solution #1 below (Atomic SQL UPDATE) addresses the CRITICAL lost update problem with minimal code changes and no infrastructure requirements.
Race Conditions
RC#1: Product Object Staleness
Location: OrderProcessingService.cs:1248 - MoveShoppingCartItemsToOrderItemsAsync()
Description:
Product is loaded once at the start of order item processing and stored in memory. If order processing takes time (payment, validation, etc.), the in-memory StockQuantity becomes stale. Another concurrent order could decrement inventory between the initial load and the actual decrement operation.
Scenario:
Time | Customer A | Customer B | DB Stock
------|-------------------------------|-------------------------------|----------
T0 | Load Product (Stock=100) | | 100
T1 | | Load Product (Stock=100) | 100
T2 | Process payment... | | 100
T3 | | Process payment... | 100
T4 | Decrement: Set Stock=95 | | 95
T5 | | Decrement: Set Stock=97 | 97 β
------|-------------------------------|-------------------------------|----------
Result: Should be 90, but is 97 (Lost 5 units from Customer A's order)
Current Mitigation:
- Customer-level mutex lock using
customer.Id(Line 1612-1651) - Limitation: Only prevents concurrent orders from SAME customer
Risk Level: π΄ HIGH
Likelihood: MEDIUM (Higher during flash sales, popular products)
Impact: Inventory discrepancy, potential overselling, inventory discrepancies
RC#2: No Transaction Wrapping
Location: OrderProcessingService.cs:1248-1310 - Order save + inventory decrement
Description:
Order placement consists of multiple database operations executed without transaction coordination:
- Save Order record (committed to DB)
- Create OrderItem records (committed to DB)
- Decrement inventory via
AdjustInventoryAsync()(separate operation)
If step 3 fails (database error, connection loss, exception), the order exists but inventory remains unchanged.
Scenario:
Step 1: INSERT INTO [Order] VALUES (...) β Committed
Step 2: INSERT INTO OrderItem VALUES (...) β Committed
Step 3: UPDATE Product SET StockQuantity = ... β Connection lost
Result: Order #5678 shows as placed, customer charged, but inventory not decremented
Current Behavior: No rollback mechanism - data inconsistency persists
Risk Level: π‘ MEDIUM-HIGH
Likelihood: LOW (Database failures are rare)
Impact: Silent inventory discrepancies, manual reconciliation required
RC#3: Multi-Warehouse Two-Phase Commit
Location: ProductService.cs:406-441 - ReserveInventoryAsync()
Description:
Multi-warehouse inventory uses a two-phase pattern:
- Phase 1 (Order Placement):
ReserveInventoryAsync()incrementsReservedQuantity - Phase 2 (Shipment Creation):
BookReservedInventoryAsync()decrementsStockQuantityandReservedQuantity
The reservation operation reads ProductWarehouseInventory records, calculates allocation, and updates without locking. Concurrent orders can both read stale data.
Code Analysis:
// ProductService.cs:406-441 - ReserveInventoryAsync()
var productInventory = _productWarehouseInventoryRepository.Table
.Where(pwi => pwi.ProductId == product.Id)
.OrderByDescending(pwi => pwi.StockQuantity - pwi.ReservedQuantity)
.ToList(); // NO LOCKING!
foreach (var item in productInventory)
{
var selectQty = Math.Min(Math.Max(0, item.StockQuantity - item.ReservedQuantity), qty);
item.ReservedQuantity += selectQty; // RACE CONDITION HERE
qty -= selectQty;
}
await UpdateProductWarehouseInventoryAsync(productInventory);Scenario:
Warehouse A: StockQuantity=10, ReservedQuantity=0
Order 1: Reads (Available=10), reserves 8 units
Order 2: Reads (Available=10), reserves 5 units (concurrently)
Order 1 commits: ReservedQuantity=8
Order 2 commits: ReservedQuantity=13 (overwrites, should be 13 total but only 10 available)
Risk Level: π‘ MEDIUM
Likelihood: LOW (Multi-warehouse less common, but still a risk)
Impact: Over-reservation, potential fulfillment issues
RC#4: Lost Updates - Last Write Wins β οΈ CRITICAL
Location: ProductService.cs:1832 - The single line where ALL inventory changes occur
Description:
This is the MOST CRITICAL race condition. The inventory update is a classic read-modify-write operation without any locking:
// ProductService.cs:1832 - THE CRITICAL LINE
product.StockQuantity += quantityToChange;
await UpdateProductAsync(product);This generates SQL like:
UPDATE Product SET StockQuantity = @newValue WHERE Id = @productIdNOT like:
UPDATE Product SET StockQuantity = StockQuantity + @change WHERE Id = @productIdThe Problem: Multiple threads can read the same value, calculate independently, and the last write overwrites all previous writes.
Detailed Scenario:
Initial State: Product.StockQuantity = 100
Thread A (Order for 5 units):
T0: Read Product β StockQuantity = 100
T1: Calculate: 100 + (-5) = 95
Thread B (Order for 3 units):
T0: Read Product β StockQuantity = 100
T1: Calculate: 100 + (-3) = 97
Thread A:
T2: Execute: UPDATE Product SET StockQuantity = 95 WHERE Id = 1234
Thread B:
T3: Execute: UPDATE Product SET StockQuantity = 97 WHERE Id = 1234
Final State: StockQuantity = 97 (WRONG! Should be 92)
Lost Update: Thread A's decrement of 5 units is completely lost
Why This Is Critical:
- β Directly causes inventory overselling
- β Affects ANY concurrent orders (not just same customer)
- β Happens in both single and multi-warehouse scenarios
- β Silent failure - no exception thrown
- β Cumulative effect - multiple lost updates compound the problem
Current Protection: β οΈ NONE - Customer-level mutex doesn't help here
Detailed Solution Analysis
Solution 1: Atomic SQL UPDATE Statement β RECOMMENDED
Approach:
Replace the read-modify-write pattern with an atomic SQL UPDATE that modifies the column in-place:
// Modified AdjustInventoryAsync() in ProductService.cs
public virtual async Task AdjustInventoryAsync(Product product, int quantityToChange,
string attributesXml = "", string message = "")
{
ArgumentNullException.ThrowIfNull(product);
if (quantityToChange == 0)
return;
if (product.ManageInventoryMethod == ManageInventoryMethod.ManageStock)
{
if (!product.UseMultipleWarehouses)
{
// ATOMIC UPDATE - THE FIX
var sql = @"
UPDATE Product
SET StockQuantity = StockQuantity + @QuantityChange
OUTPUT INSERTED.StockQuantity
WHERE Id = @ProductId
AND (
@QuantityChange >= 0 -- Allowing increase always
OR StockQuantity + @QuantityChange >= 0 -- Allowing decrease only if enough stock
)";
var result = await _dataProvider.QueryAsync<int?>(sql, new
{
QuantityChange = quantityToChange,
ProductId = product.Id
}).FirstOrDefaultAsync();
if (result == null && quantityToChange < 0)
{
// No rows affected = insufficient stock
throw new NopException(
await _localizationService.GetResourceAsync("Products.InsufficientStock"));
}
var newStockQuantity = result ?? product.StockQuantity + quantityToChange;
// Create history entry with actual new quantity
await AddStockQuantityHistoryEntryAsync(product, quantityToChange,
newStockQuantity, product.WarehouseId, message);
// Update in-memory object for caller
product.StockQuantity = newStockQuantity;
// Check for low stock notifications
if (quantityToChange < 0 && newStockQuantity < product.NotifyAdminForQuantityBelow)
{
await SendLowStockNotificationsAsync(product);
}
}
else
{
// Multi-warehouse: Similar atomic update for ProductWarehouseInventory
await ReserveInventoryAtomicAsync(product, quantityToChange);
}
}
// Handle attribute combinations...
}
// Similar atomic update for ProductWarehouseInventory
protected virtual async Task ReserveInventoryAtomicAsync(Product product, int quantity)
{
var sql = @"
UPDATE ProductWarehouseInventory
SET ReservedQuantity = ReservedQuantity + @Quantity
WHERE ProductId = @ProductId
AND WarehouseId = (
SELECT TOP 1 WarehouseId
FROM ProductWarehouseInventory
WHERE ProductId = @ProductId
ORDER BY (StockQuantity - ReservedQuantity) DESC
)
AND (StockQuantity - ReservedQuantity) >= ABS(@Quantity)";
var rowsAffected = await _dataProvider.ExecuteNonQueryAsync(sql,
new { Quantity = -quantity, ProductId = product.Id });
if (rowsAffected == 0)
throw new NopException("Insufficient stock in warehouses");
}SQL Explanation:
-- Instead of:
-- 1. SELECT StockQuantity FROM Product WHERE Id = 1234 (Read = 100)
-- 2. Calculate in app: 100 + (-5) = 95
-- 3. UPDATE Product SET StockQuantity = 95 WHERE Id = 1234 (Write 95)
-- Do this (atomic):
UPDATE Product
SET StockQuantity = StockQuantity + (-5) -- Calculation happens in DB atomically
OUTPUT INSERTED.StockQuantity -- Return new value
WHERE Id = 1234
AND StockQuantity + (-5) >= 0 -- Prevent negative stock
-- If WHERE clause fails (insufficient stock), 0 rows affected
-- No exception, just check resultPros:
- β Solves RC#4 completely - No more lost updates
- β Atomic operation at database level - Database handles concurrency
- β Prevents negative inventory naturally - Built into WHERE clause
- β Best performance - No locking overhead, single database round-trip
- β Simple to implement - Focused code changes
- β Works across multiple servers - Database-level atomicity
- β No external dependencies - Works with existing SQL Server
- β Testable - Easy to verify with concurrent load tests
Cons:
β οΈ Requires raw SQL or careful LINQ construction (LinqToDB supports both)β οΈ Error handling changes (check affected rows instead of exception)β οΈ Must apply to all inventory update points (single warehouse, multi-warehouse, attribute combinations)β οΈ Doesn't solve transaction atomicity (RC#2)
Implementation Checklist:
-
ProductService.cs:1832- AdjustInventoryAsync() for single warehouse -
ProductService.cs:406- ReserveInventoryAsync() for multi-warehouse -
ProductService.cs:1857- Attribute combination stock updates -
ProductService.cs:1918- BookReservedInventoryAsync() - Add unit tests for concurrent scenarios
- Add integration tests with actual database
- Load test with 100+ concurrent orders
Addresses: RC#5 (Lost Updates) - COMPLETELY
Complexity: LOW-MEDIUM (focused changes)
Performance Impact: VERY LOW (faster than current approach)
Risk Level: LOW (standard SQL pattern)
Why This Is The Best First Step:
- 95% effectiveness with 10% effort
- No infrastructure changes
- Minimal risk
- Immediate impact on the most critical issue
- Can be implemented and tested in 1-2 days
Solution 2: Atomic SQL UPDATE + Transaction Wrapping ββ HIGHLY RECOMMENDED
The Best Balance of Effectiveness and Simplicity
// In OrderProcessingService.SaveOrderDetailsAsync()
public async Task<Order> SaveOrderDetailsAsync(ProcessPaymentRequest processPaymentRequest,
ProcessPaymentResult processPaymentResult)
{
// Get database connection from LinqToDB
using (var dataConnection = _dataProvider.CreateDataConnection())
using (var transaction = await dataConnection.BeginTransactionAsync())
{
try
{
// 1. Save order
var order = await PrepareOrderAsync(processPaymentRequest, processPaymentResult);
await dataConnection.InsertAsync(order);
// 2. Create order items with atomic inventory updates
foreach (var cartItem in processPaymentRequest.ShoppingCart)
{
var orderItem = await PrepareOrderItemAsync(cartItem, order);
await dataConnection.InsertAsync(orderItem);
// 3. ATOMIC INVENTORY UPDATE (within transaction)
var sql = @"
UPDATE Product
SET StockQuantity = StockQuantity + @Change
OUTPUT INSERTED.StockQuantity
WHERE Id = @ProductId
AND (StockQuantity + @Change >= 0 OR @Change >= 0)";
var newStock = await dataConnection.ExecuteAsync<int?>(sql, new
{
Change = -cartItem.Quantity,
ProductId = cartItem.ProductId
}).FirstOrDefaultAsync();
if (newStock == null && cartItem.Quantity > 0)
{
// Insufficient stock - rollback entire transaction
throw new NopException(
$"Insufficient stock for product {cartItem.ProductId}");
}
// 4. Create stock history (within same transaction)
await dataConnection.InsertAsync(new StockQuantityHistory
{
ProductId = cartItem.ProductId,
QuantityAdjustment = -cartItem.Quantity,
StockQuantity = newStock.Value,
Message = $"Order #{order.Id} placement",
CreatedOnUtc = DateTime.UtcNow
});
}
// 5. Delete shopping cart items
await dataConnection.DeleteAsync(processPaymentRequest.ShoppingCart);
// All succeeded - commit
await transaction.CommitAsync();
return order;
}
catch (Exception ex)
{
// Any failure - rollback entire transaction
await transaction.RollbackAsync();
await _logger.ErrorAsync($"Order placement failed, transaction rolled back: {ex.Message}", ex);
throw;
}
}
}Why This Is The Best Approach:
β
Solves RC#5 (Lost Updates): Atomic SQL UPDATE eliminates read-modify-write race condition
β
Solves RC#2 (Transaction Atomicity): Order + inventory guaranteed consistent
β
Works multi-server: Database-level atomicity and transactions
β
Best performance: Single transaction, no locking overhead beyond database internals
β
Prevents negative inventory: Built into WHERE clause
β
Simple error handling: Transaction rollback on any failure
β
No infrastructure changes: Works with existing SQL Server + LinqToDB
β
Testable: Standard integration testing patterns
Transaction Scope:
START TRANSACTION
INSERT INTO [Order] (...)
INSERT INTO [OrderItem] (...)
UPDATE Product SET StockQuantity = StockQuantity - 5 WHERE ... β Atomic
INSERT INTO StockQuantityHistory (...)
DELETE FROM ShoppingCartItem (...)
COMMIT
Important: Payment Processing Stays Outside:
// BEFORE transaction:
var paymentResult = await ProcessPaymentAsync(processPaymentRequest);
// THEN start transaction for database operations
using (var transaction = BeginTransaction())
{
// Save order with payment result
// Adjust inventory
// ...
}Implementation Checklist:
- Refactor SaveOrderDetailsAsync() to use explicit transaction
- Implement atomic UPDATE for single-warehouse inventory
- Implement atomic UPDATE for multi-warehouse inventory (ReserveInventoryAsync)
- Implement atomic UPDATE for attribute combinations
- Unit tests for transaction rollback scenarios
- Integration tests with actual database
- Load test: 100 concurrent orders for product with StockQuantity=50 (expect exactly 50 success, 50 fail)
Addresses: RC#2 (Transaction Atomicity) + RC#4 (Lost Updates) = ~98% of issues
Complexity: MEDIUM (focused changes in 2-3 files)
Performance Impact: LOW (actually faster than current approach)
Risk Level: LOW-MEDIUM (standard patterns)
Scalability: EXCELLENT (multi-server)
This is the TOP RECOMMENDATION for a production implementation.
#Attached file has implementation as per the v4.8.3 of the ProductService
changes commented as:
/* WZ MODIFICATION /
//MFA 2026-01-23 resolve Inventory Race Condition
.. changes ..
/ TO HERE */