Optimistic Concurrency Control
Overview
Optimistic concurrency assumes conflicts are rare. It allows concurrent access but detects conflicts when saving, letting you handle them appropriately.
Concurrency Token Setup
Using Timestamp/RowVersion
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; } // Concurrency token
}
// Or with Fluent API
modelBuilder.Entity<Product>()
.Property(p => p.RowVersion)
.IsRowVersion();
Using Any Property as Concurrency Token
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
[ConcurrencyCheck]
public DateTime LastModified { get; set; }
}
// Or Fluent API
modelBuilder.Entity<Product>()
.Property(p => p.LastModified)
.IsConcurrencyToken();
How It Works
// User A loads product
var productA = await contextA.Products.FindAsync(1);
// RowVersion = 0x00000001
// User B loads same product
var productB = await contextB.Products.FindAsync(1);
// RowVersion = 0x00000001
// User A updates and saves
productA.Price = 99.99m;
await contextA.SaveChangesAsync();
// UPDATE Products SET Price = 99.99, RowVersion = 0x00000002
// WHERE Id = 1 AND RowVersion = 0x00000001
// RowVersion now 0x00000002
// User B tries to save
productB.Name = "Updated Name";
await contextB.SaveChangesAsync();
// UPDATE Products SET Name = 'Updated Name', RowVersion = 0x00000002
// WHERE Id = 1 AND RowVersion = 0x00000001 // Fails! RowVersion changed
// Throws DbUpdateConcurrencyException
Handling Concurrency Conflicts
Basic Handling
try
{
product.Price = 99.99m;
await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
Console.WriteLine("Concurrency conflict detected!");
// Handle conflict
}
Client Wins Strategy
try
{
await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
foreach (var entry in ex.Entries)
{
// Get current database values
var databaseValues = await entry.GetDatabaseValuesAsync();
if (databaseValues == null)
{
// Entity was deleted
throw new Exception("Entity was deleted by another user");
}
// Client wins - overwrite database values
entry.OriginalValues.SetValues(databaseValues);
// Retry save
await context.SaveChangesAsync();
}
}
Database Wins Strategy
catch (DbUpdateConcurrencyException ex)
{
foreach (var entry in ex.Entries)
{
var databaseValues = await entry.GetDatabaseValuesAsync();
if (databaseValues != null)
{
// Database wins - refresh client values
entry.CurrentValues.SetValues(databaseValues);
}
// Don't retry - client gets latest values
}
}
Merge Strategy
catch (DbUpdateConcurrencyException ex)
{
foreach (var entry in ex.Entries)
{
if (entry.Entity is Product product)
{
var databaseEntry = await entry.GetDatabaseValuesAsync();
var databaseProduct = (Product)databaseEntry.ToObject();
// Merge: Keep client's price, database's name
product.Name = databaseProduct.Name; // Database wins for Name
// product.Price already has client value (client wins for Price)
// Update original values to retry
entry.OriginalValues.SetValues(databaseEntry);
await context.SaveChangesAsync();
}
}
}
User Decision Strategy
catch (DbUpdateConcurrencyException ex)
{
foreach (var entry in ex.Entries)
{
var currentValues = entry.CurrentValues;
var databaseValues = await entry.GetDatabaseValuesAsync();
var originalValues = entry.OriginalValues;
// Show user the conflict
Console.WriteLine("Conflict detected:");
foreach (var property in currentValues.Properties)
{
var current = currentValues[property];
var database = databaseValues[property];
var original = originalValues[property];
if (!Equals(current, database))
{
Console.WriteLine($"{property.Name}:");
Console.WriteLine($" Your value: {current}");
Console.WriteLine($" Database value: {database}");
Console.WriteLine($" Original value: {original}");
}
}
// Let user choose
var choice = GetUserChoice(); // Your UI logic
if (choice == "mine")
{
entry.OriginalValues.SetValues(databaseValues);
}
else
{
entry.CurrentValues.SetValues(databaseValues);
}
}
}
Real-World Scenarios
E-Commerce: Stock Management
public class Product
{
public int Id { get; set; }
public int StockQuantity { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; }
}
// Two users try to buy last item
public async Task<bool> PurchaseProduct(int productId, int quantity)
{
using var transaction = await context.Database.BeginTransactionAsync();
try
{
var product = await context.Products.FindAsync(productId);
if (product.StockQuantity < quantity)
{
return false; // Not enough stock
}
product.StockQuantity -= quantity;
await context.SaveChangesAsync();
await transaction.CommitAsync();
return true;
}
catch (DbUpdateConcurrencyException)
{
await transaction.RollbackAsync();
// Reload and retry
context.Entry(product).Reload();
if (product.StockQuantity >= quantity)
{
return await PurchaseProduct(productId, quantity); // Retry
}
return false; // Lost race
}
}
Banking: Account Balance
public class Account
{
public int Id { get; set; }
public decimal Balance { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; }
}
public async Task<bool> Transfer(int fromId, int toId, decimal amount)
{
var maxRetries = 3;
var attempt = 0;
while (attempt < maxRetries)
{
try
{
var from = await context.Accounts.FindAsync(fromId);
var to = await context.Accounts.FindAsync(toId);
if (from.Balance < amount)
return false;
from.Balance -= amount;
to.Balance += amount;
await context.SaveChangesAsync();
return true;
}
catch (DbUpdateConcurrencyException)
{
attempt++;
// Reload both accounts
await context.Entry(from).ReloadAsync();
await context.Entry(to).ReloadAsync();
if (attempt == maxRetries)
throw; // Give up after retries
}
}
return false;
}
Testing Concurrency
[Fact]
public async Task ConcurrentUpdate_ThrowsConcurrencyException()
{
// Arrange
var product = new Product { Name = "Test", Price = 100 };
await _context.Products.AddAsync(product);
await _context.SaveChangesAsync();
// Create two contexts (simulate two users)
await using var context1 = CreateContext();
await using var context2 = CreateContext();
// Both load same entity
var product1 = await context1.Products.FindAsync(product.Id);
var product2 = await context2.Products.FindAsync(product.Id);
// User 1 updates
product1.Price = 120;
await context1.SaveChangesAsync();
// User 2 tries to update
product2.Price = 110;
// Assert
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(
() => context2.SaveChangesAsync());
}
Performance Considerations
// RowVersion adds overhead to every UPDATE
// But prevents lost updates
// Bad: Loading just to update
var product = await context.Products.FindAsync(id);
product.Price = newPrice;
await context.SaveChangesAsync();
// Requires: SELECT + UPDATE
// Better: Update without loading (but no concurrency check)
await context.Products
.Where(p => p.Id == id)
.ExecuteUpdateAsync(p => p.SetProperty(x => x.Price, newPrice));
// Only UPDATE, but bypasses concurrency token!
// Best: Load only when needed
if (needsConcurrencyCheck)
{
var product = await context.Products.FindAsync(id);
product.Price = newPrice;
await context.SaveChangesAsync();
}
else
{
await context.Products
.Where(p => p.Id == id)
.ExecuteUpdateAsync(p => p.SetProperty(x => x.Price, newPrice));
}
Interview Questions
Q: What is optimistic concurrency?
A: Optimistic concurrency assumes conflicts are rare and allows concurrent access. It detects conflicts when saving using a concurrency token (usually a timestamp/rowversion), then lets you handle the conflict.
Q: How does a concurrency token work?
A: A concurrency token (like RowVersion) is included in the WHERE clause of UPDATE/DELETE. If another user changed the record, the token won't match and the operation fails, throwing DbUpdateConcurrencyException.
Q: What are common strategies for handling conflicts?
A:
- Client Wins: Overwrite database with client changes
- Database Wins: Discard client changes, use database values
- Merge: Combine changes based on rules
- User Decision: Show conflict and let user choose
Q: When should you use optimistic vs pessimistic concurrency?
A:
- Optimistic: Most scenarios, when conflicts are rare, web applications
- Pessimistic: High conflict scenarios, critical operations (use database locks)