RO EN

Cosmos DB patterns (4) — optimistic concurrency cu ETags

Cosmos DB patterns (4) — optimistic concurrency cu ETags ✨ Imagine generată cu AI
Doru Bulubașa
30 iulie 2026
30 vizualizări

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: /MessageCount trebuie 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.