A patra parte din seria despre pattern-uri avansate în Cosmos DB. Partea anterioară a tratat conflictele dintre regiuni. Acum coborâm la conflictele de zi cu zi: două procese care modifică același document.
Anatomia unui lost update
Scenariu concret: contorul de mesaje al unei sesiuni de chat, cu limită per sesiune. Două request-uri sosesc simultan:
Proces A: citeste sesiunea (messageCount = 5)
Proces B: citeste sesiunea (messageCount = 5)
Proces A: seteaza 6, salveaza --> OK
Proces B: seteaza 6, salveaza --> OK, dar a STERS scrierea lui A
Realitate: 7 mesaje. In baza de date: 6. Limita e ocolita.
Ambele scrieri au reușit, niciuna n-a eșuat, dar una a dispărut. Ăsta e lost update — și cu cât ai mai multe instanțe (scalarea din seria Container Apps!), cu atât e mai frecvent.
ETag: versiunea implicită a fiecărui document
Fiecare document Cosmos DB are proprietatea de sistem _etag — se schimbă automat la fiecare scriere. Optimistic concurrency înseamnă: la salvare, declari ce versiune ai citit; dacă între timp s-a schimbat, scrierea e respinsă.
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!; // populat automat la citire
}
public async Task IncrementMessageCountAsync(string sessionId, string tenantId)
{
var response = await _container.ReadItemAsync<ChatSession>(
sessionId, new PartitionKey(tenantId));
var session = response.Resource;
session.MessageCount++;
// Scrierea reuseste DOAR daca documentul nu s-a schimbat intre timp
await _container.ReplaceItemAsync(
session,
session.Id,
new PartitionKey(tenantId),
new ItemRequestOptions
{
IfMatchEtag = session.ETag
});
}
Dacă alt proces a modificat documentul între citirea și scrierea ta, primești CosmosException cu status 412 PreconditionFailed. Nimic nu s-a pierdut — doar că trebuie să reiei.
Retry-ul corect: re-citește, re-aplică, re-încearcă
Greșeala clasică la 412: retry pe aceeași scriere, cu același ETag vechi — va eșua identic la nesfârșit. Retry-ul corect reia întregul ciclu: citire proaspătă, re-aplicarea modificării pe starea nouă, scriere cu ETag-ul nou:
public async Task<bool> TryIncrementWithLimitAsync(
string sessionId, string tenantId, int maxMessages,
int maxAttempts = 3)
{
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
// 1. Citire PROASPATA la fiecare incercare
var response = await _container.ReadItemAsync<ChatSession>(
sessionId, new PartitionKey(tenantId));
var session = response.Resource;
// 2. Logica de business pe starea curenta
if (session.MessageCount >= maxMessages)
return false; // limita atinsa -- corect si sub concurenta
session.MessageCount++;
try
{
// 3. Scriere conditionala
await _container.ReplaceItemAsync(
session, session.Id, new PartitionKey(tenantId),
new ItemRequestOptions { IfMatchEtag = session.ETag });
return true;
}
catch (CosmosException ex)
when (ex.StatusCode == HttpStatusCode.PreconditionFailed)
{
// Alt proces a modificat -- reluam ciclul complet
_logger.LogDebug(
"Conflict ETag pe sesiunea {Id}, incercarea {N}",
sessionId, attempt);
}
}
throw new ConcurrencyException(
$"Sesiunea {sessionId}: prea multe conflicte concurente");
}
Observă că verificarea limitei stă în interiorul buclei: sub concurență, decizia se ia mereu pe starea proaspătă. Exact asta face pattern-ul potrivit pentru limite per sesiune, cote de utilizare și orice invariant read-modify-write.
Alternativa pentru operații punctuale: Patch
Când modificarea e o operație simplă pe un câmp (increment, set), Partial Document Update evită complet ciclul read-modify-write — serverul aplică operația atomic:
// Increment atomic pe server -- fara citire, fara ETag, fara retry
await _container.PatchItemAsync<ChatSession>(
sessionId,
new PartitionKey(tenantId),
new[]
{
PatchOperation.Increment("/MessageCount", 1)
});
Două nuanțe:
- Casing-ul path-urilor — lecție plătită scump:
/MessageCounttrebuie să corespundă exact proprietății din documentul stocat. Casing greșit = eroare, nu no-op. - Patch nu vede logica de business — incrementul e atomic, dar nu verifică limita. Pentru „incrementează doar dacă sub limită”, poți adăuga o condiție de tip filter predicate la patch, sau rămâi pe ciclul cu ETag.
// Patch conditionat -- incrementeaza doar sub limita
await _container.PatchItemAsync<ChatSession>(
sessionId, new PartitionKey(tenantId),
new[] { PatchOperation.Increment("/MessageCount", 1) },
new PatchItemRequestOptions
{
FilterPredicate = "FROM c WHERE c.MessageCount < 100"
});
// 412 daca predicatul nu se indeplineste
Cum alegi
| Situație | Alegere |
|---|---|
| Increment / set simplu pe un câmp | Patch |
| Increment cu condiție simplă | Patch cu FilterPredicate |
| Logică de business pe mai multe câmpuri | ETag + retry |
| Update pe document întreg din UI (formular) | ETag — detectezi editările concurente |
Ce urmează
Toate pattern-urile din serie merită teste automate — dar nu pe contul de producție. În ultima parte: Cosmos DB Emulator în CI/CD, cu GitHub Actions, teste NUnit reale și capcanele de certificat și timing.
Întrebări? Scrie-mi la contact@ludoprogramming.com.