The fourth part of the series about advanced patterns in Cosmos DB. The previous part dealt with conflicts between regions. Now we go down to everyday conflicts: two processes modifying the same document.
Anatomy of a lost update
Concrete scenario: the message counter of a chat session, with a limit per session. Two requests arrive simultaneously:
Process A: reads the session (messageCount = 5)
Process B: reads the session (messageCount = 5)
Process A: sets 6, saves --> OK
Process B: sets 6, saves --> OK, but it ERASED A's write
Reality: 7 messages. In the database: 6. The limit is bypassed.
Both writes succeeded, none failed, but one disappeared. This is a lost update — and the more instances you have (scaling from the Container Apps series!), the more frequent it is.
ETag: the default version of each document
Each Cosmos DB document has the system property _etag — it changes automatically with every write. Optimistic concurrency means: when saving, you declare which version you read; if it changed in the meantime, the write is rejected.
public class ChatSession
{
public string Id { get; set; } = default!;
public string TenantId { get; set; } = default!;
public int MessageCount { get; set; }
[JsonProperty("_etag")]
public string ETag { get; set; } = default!; // automatically populated on read
}
public async Task IncrementMessageCountAsync(string sessionId, string tenantId)
{
var response = await _container.ReadItemAsync<ChatSession>(
sessionId, new PartitionKey(tenantId));
var session = response.Resource;
session.MessageCount++;
// Write succeeds ONLY if the document has not changed in the meantime
await _container.ReplaceItemAsync(
session,
session.Id,
new PartitionKey(tenantId),
new ItemRequestOptions
{
IfMatchEtag = session.ETag
});
}
If another process modified the document between your read and write, you get a CosmosException with status 412 PreconditionFailed. Nothing is lost — you just need to retry.
The correct retry: re-read, re-apply, re-try
The classic mistake on 412: retrying the same write with the same old ETag — it will fail identically forever. The correct retry repeats the entire cycle: fresh read, re-application of the change on the new state, write with the new ETag:
public async Task<bool> TryIncrementWithLimitAsync(
string sessionId, string tenantId, int maxMessages,
int maxAttempts = 3)
{
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
// 1. FRESH read on each attempt
var response = await _container.ReadItemAsync<ChatSession>(
sessionId, new PartitionKey(tenantId));
var session = response.Resource;
// 2. Business logic on the current state
if (session.MessageCount >= maxMessages)
return false; // limit reached -- correct even under concurrency
session.MessageCount++;
try
{
// 3. Conditional write
await _container.ReplaceItemAsync(
session, session.Id, new PartitionKey(tenantId),
new ItemRequestOptions { IfMatchEtag = session.ETag });
return true;
}
catch (CosmosException ex)
when (ex.StatusCode == HttpStatusCode.PreconditionFailed)
{
// Another process modified -- restart the full cycle
_logger.LogDebug(
"ETag conflict on session {Id}, attempt {N}",
sessionId, attempt);
}
}
throw new ConcurrencyException(
$"Session {sessionId}: too many concurrent conflicts");
}
Note that the limit check is inside the loop: under concurrency, the decision is always made on fresh state. This is exactly what the pattern suitable for per-session limits, usage quotas, and any read-modify-write invariant does.
Alternative for point operations: Patch
When the change is a simple operation on a field (increment, set), Partial Document Update completely avoids the read-modify-write cycle — the server applies the operation atomically:
// Atomic increment on the server -- no read, no ETag, no retry
await _container.PatchItemAsync<ChatSession>(
sessionId,
new PartitionKey(tenantId),
new[]
{
PatchOperation.Increment("/MessageCount", 1)
});
Two nuances:
- Path casing — a costly lesson:
/MessageCountmust match the property in the stored document exactly. Wrong casing = error, not a no-op. - Patch does not see business logic — the increment is atomic but does not check the limit. For "increment only if under limit," you can add a filter predicate condition to the patch, or stick with the ETag cycle.
// Conditional patch -- increment only if under limit
await _container.PatchItemAsync<ChatSession>(
sessionId, new PartitionKey(tenantId),
new[] { PatchOperation.Increment("/MessageCount", 1) },
new PatchItemRequestOptions
{
FilterPredicate = "FROM c WHERE c.MessageCount < 100"
});
// 412 if the predicate is not met
How to choose
| Situation | Choice |
|---|---|
| Simple increment / set on a field | Patch |
| Increment with simple condition | Patch with FilterPredicate |
| Business logic on multiple fields | ETag + retry |
| Update entire document from UI (form) | ETag — detect concurrent edits |
What’s next
All patterns in the series deserve automated tests — but not on the production account. In the final part: Cosmos DB Emulator in CI/CD, with GitHub Actions, real NUnit tests, and certificate and timing pitfalls.
Questions? Write to me at contact@ludoprogramming.com.