Optimistic Concurrency (ETag)
Optimistic concurrency protects against the lost update problem: when two callers read the same entity and both write it back, the second write would silently overwrite the first. With an eTag guard the second write is rejected instead, so no update is lost.
This feature is implemented for the Azure Cosmos DB and the in-memory provider, with the same semantics, so concurrency behaviour can be covered by unit tests against the in-memory provider.
Opting in
Concurrency is opt-in per entity. Add a nullable string property marked with the
[ETag] attribute:
using Wemogy.Infrastructure.Database.Core.Abstractions;
using Wemogy.Infrastructure.Database.Core.Attributes;
public class User : EntityBase
{
[PartitionKey]
public string TenantId { get; set; } = string.Empty;
public string Firstname { get; set; } = string.Empty;
[ETag]
public string? ETag { get; init; }
}
Entities without an [ETag] property are unaffected and continue to use
unconditional writes.
How it works
The [ETag] property is bound to Cosmos' system _etag field through two
serialization rules that are applied automatically:
- Read: the property is populated from the store's
_etagon every read. - Write: the property is never serialized into the document body, so a stale value can never be persisted.
On ReplaceAsync, the eTag that the entity was read with is sent as an If-Match
condition. If the document changed in the meantime, Cosmos rejects the write with
HTTP 412 and the library throws a PreconditionFailedErrorException.
Automatic retries
The UpdateAsync methods read the entity, apply your change and replace it. When a
concurrent write causes a PreconditionFailedErrorException, the operation is
retried automatically with an exponential backoff (3 retries) via a Polly retry
policy. Each retry re-reads the entity (getting the fresh eTag) and re-applies your
update action, so conflicting updates converge instead of being lost.
// If another writer updates the user between the read and the write,
// this update is retried automatically against the fresh version.
var updated = await userRepository.UpdateAsync(
user.Id,
user.TenantId,
u => u.Firstname = "Updated");
If the conflict persists beyond the retry limit, the
PreconditionFailedErrorException is surfaced to the caller.
eTag or a conditional patch?
Both protect a write against a concurrent one, and they answer different questions:
- An eTag says "read, decide, write - and tell me if it moved in the meantime". The decision
happens in your code, so the write needs a read, and a conflict is resolved by reading again.
That is what
UpdateAsyncdoes for you. - A conditional patch says "apply this only if the state still permits it". The decision happens in the database, so the write needs neither a read nor a retry, and a condition that does not hold is an answer rather than a conflict.
For a counter, a quota or a flag, prefer the conditional patch. For a change that only your code
can compute from the current state, use UpdateAsync and let the eTag guard it.
A bare ReplaceAsync of a stale entity is not recoverable by retrying, because it
carries the same outdated eTag on every attempt. After the retries are exhausted it
throws PreconditionFailedErrorException. Prefer UpdateAsync for read-modify-write
flows, as it re-reads the entity on each attempt.