Optimistic Concurrency¶
The provider implements optimistic concurrency by appending the original token value to the
WHERE predicate of UPDATE and DELETE statements — if another writer has changed the item since
it was loaded, DynamoDB rejects the write and the provider throws DbUpdateConcurrencyException.
Configuring a Concurrency Token¶
Mark one or more properties as concurrency tokens using either the [ConcurrencyToken]
attribute or the Fluent API:
// Attribute
public class Order
{
public string Pk { get; set; }
public string Sk { get; set; }
public string Status { get; set; }
[ConcurrencyToken]
public int Version { get; set; }
}
// Fluent API (equivalent)
modelBuilder.Entity<Order>()
.Property(x => x.Version)
.IsConcurrencyToken();
For every property marked as a concurrency token, the provider appends an equality condition to the WHERE clause of UPDATE and DELETE statements using the original value — the value the property held when the entity was loaded from DynamoDB:
UPDATE "Orders"
SET "status" = ?
WHERE "pk" = ? AND "sk" = ? AND "version" = ?
-- ^^^^^^^^^^^^^^ original version, not the new value
If the item in DynamoDB has a different version value than what was read — because another
writer updated it in the meantime — DynamoDB raises ConditionalCheckFailedException, which the
provider maps to DbUpdateConcurrencyException.
Manual Token Updates Are Required¶
Note
The provider does not auto-generate or increment concurrency token values.
`IsRowVersion()` and `ValueGeneratedOnAddOrUpdate` are not yet supported.
Your application must assign the new token value before calling SaveChangesAsync. A common
pattern is an integer version that the writer increments on each save:
var order = await db.Orders
.AsAsyncEnumerable()
.SingleAsync(o => o.Pk == pk && o.Sk == sk, cancellationToken);
order.Status = "shipped";
order.Version += 1; // must be updated before saving — the provider uses the original value in WHERE
await db.SaveChangesAsync(cancellationToken);
The change tracker snapshots the version at load time (e.g., 3) and places that snapshot in
the WHERE clause. After the update succeeds, the new value (4) becomes the current snapshot
for subsequent saves.
How Conflicts Are Detected¶
Conflict detection depends on the execution path:
- Single-entity saves (
ExecuteStatement): DynamoDB returnsConditionalCheckFailedExceptionwhen the WHERE predicate does not match. - Transactional saves (
ExecuteTransaction): DynamoDB returnsTransactionCanceledExceptionwith aConditionalCheckFailedcancellation reason for the conflicting item.
The provider requests DynamoDB's ALL_OLD value on condition-check failures and uses the
returned item to distinguish key misses from token conflicts:
- If DynamoDB returns the existing item, the key matched and the concurrency token predicate
failed; the provider throws
DbUpdateConcurrencyException. - If DynamoDB does not return an item, the target key did not match any item. The item may have
been deleted, or the tracked key values may not match the stored item; the provider throws
DbUpdateException.
The Entries property on the exception identifies which entities were involved.
Handling DbUpdateConcurrencyException¶
When a conflict is detected, the standard recovery pattern is to reload the entity from
DynamoDB and retry. ReloadAsync re-reads the item and resets all tracked values —
including the concurrency token — so the next save uses the current store state:
async Task SaveWithRetry(AppDbContext db, CancellationToken ct)
{
const int maxRetries = 3;
for (var attempt = 0; attempt < maxRetries; attempt++)
{
try
{
await db.SaveChangesAsync(ct);
return;
}
catch (DbUpdateConcurrencyException ex)
{
// Reload all conflicting entries from DynamoDB.
foreach (var entry in ex.Entries)
await entry.ReloadAsync(ct);
// Re-apply business logic if needed, then retry.
if (attempt == maxRetries - 1)
throw;
}
}
}
Note that ReloadAsync resets the entity to the current store values. If your business logic
depends on the values that were being written (e.g., incrementing a counter), you must
re-apply those mutations after reloading.
INSERT Duplicate Key¶
When an Added entity's primary key already exists in DynamoDB, the insert fails with
DuplicateItemException. The provider maps this to DbUpdateException, not
DbUpdateConcurrencyException:
try
{
db.Orders.Add(new Order { Pk = "CUSTOMER#42", Sk = "ORDER#001", ... });
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException ex)
{
// Item with this PK+SK already exists.
}
The distinction matters: DbUpdateConcurrencyException means a stale-read conflict (another
writer changed an item you already read). DbUpdateException on a duplicate key means the item
never existed in your read — it is a uniqueness violation, not a concurrency conflict. The
retry-with-reload pattern does not apply here.
DELETE and Missing Items¶
Deleting an entity that no longer exists in DynamoDB is a silent success — DynamoDB returns success when the WHERE predicate matches no item. The provider does not treat this as an error because the goal of DELETE is for the item not to exist; if it is already gone, the outcome is the same.
If concurrency tokens are configured, the token value is included in the WHERE predicate. This creates an important asymmetry:
- Item missing entirely → silent success for singleton deletes (provider accepts the delete
as done) or
DbUpdateExceptionif DynamoDB reports a failed condition on that write path. - Item present with a different token value →
ConditionalCheckFailedExceptionwith old item attributes →DbUpdateConcurrencyException.
The second case matters when two writers race to delete the same item: the first delete succeeds and the item is gone; the second delete finds the item missing and also succeeds. But if another writer updates the item (changing its token) before your delete runs, you get a concurrency conflict — DynamoDB found the item but the token predicate didn't match.