EFEF Core Handbook

İLERİ

Concurrency Tokens

Aynı kaydı iki kullanıcı aynı anda güncellemeye çalıştığında veri kaybını önleyen mekanizma. EF Core "optimistic concurrency" kullanır — kayıt okunduğundaki versiyonu saklar, UPDATE sırasında versiyon değişmişse hata fırlatır.

Veritabanı sağlayıcısı Bu sayfadaki eşleşen örnekleri seçilen sağlayıcıya göre gösterir.

21.1 Optimistic vs Pessimistic Concurrency

Strateji Açıklama EF Core Desteği
Optimistic Çakışma olursa kaydetme anında yakala Varsayılan
Pessimistic Satırı kilitle, başkası okumasın EF desteklemez (Raw SQL ile mümkün)

EF Core optimistic concurrency kullanır: Okuma sırasında kilit almaz, yazma anında "bu kayıt okunduğundan beri değişmiş mi?" kontrolü yapar.

21.2 RowVersion (Timestamp) Token

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public int Stock { get; set; }

    [Timestamp]                          // Data Annotation
    public byte[] RowVersion { get; set; }
}

// Fluent API (EntityTypeConfiguration)
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        builder.Property(p => p.RowVersion)
               .IsRowVersion();          // byte[] → SQL Server ROWVERSION
    }
}

Üretilen SQL:

CREATE TABLE [Products] (
    [Id]         INT            IDENTITY(1,1) NOT NULL,
    [Name]       NVARCHAR(200)  NOT NULL,
    [Price]      DECIMAL(18,2)  NOT NULL,
    [Stock]      INT            NOT NULL,
    [RowVersion] ROWVERSION     NOT NULL,   -- SQL Server her UPDATE'te otomatik artırır
    CONSTRAINT [PK_Products] PRIMARY KEY ([Id])
);
CREATE TABLE products (
    id         INTEGER GENERATED ALWAYS AS IDENTITY,
    name       VARCHAR(200)  NOT NULL,
    price      NUMERIC(18,2) NOT NULL,
    stock      INTEGER       NOT NULL,
    -- PostgreSQL'de ROWVERSION yok → xmin system column kullanılır
    -- xmin: her satırın oluşturulduğu/güncellendiği transaction ID'si (otomatik, gizli)
    CONSTRAINT pk_products PRIMARY KEY (id)
);

-- xmin'e erişim:
SELECT xmin, id, name, price FROM products;
-- EF Core: builder.UseXminAsConcurrencyToken();

ROWVERSION (eski adıyla TIMESTAMP) 8-byte binary değerdir. Her UPDATE'te SQL Server otomatik artırır — uygulama kodu bu değeri hiç set etmez. PostgreSQL'de ise xmin system column aynı rolü üstlenir (gizlidir, CREATE TABLE'da görünmez).

21.3 Property-Level Concurrency Token

public class BankAccount
{
    public int Id { get; set; }
    public string Owner { get; set; }
    public decimal Balance { get; set; }    // Bu alanı korumak istiyoruz
    public DateTime LastModified { get; set; }
}

public class BankAccountConfiguration : IEntityTypeConfiguration<BankAccount>
{
    public void Configure(EntityTypeBuilder<BankAccount> builder)
    {
        // Sadece Balance alanı concurrency token
        builder.Property(b => b.Balance)
               .IsConcurrencyToken();

        // Veya LastModified ile kontrol
        builder.Property(b => b.LastModified)
               .IsConcurrencyToken();
    }
}

Üretilen UPDATE:

-- Balance concurrency token olduğunda:
UPDATE [BankAccounts]
SET [Balance] = @p0, [LastModified] = @p1
WHERE [Id] = @p2 AND [Balance] = @p3;
--                    ^^^^^^^^^^^^^^^^ orijinal değer eşleşmezse 0 satır!

SELECT @@ROWCOUNT;  -- 0 → DbUpdateConcurrencyException
-- Balance concurrency token olduğunda:
UPDATE bank_accounts
SET balance = @p0, last_modified = @p1
WHERE id = @p2 AND balance = @p3;
--                  ^^^^^^^^^^^^^^^^ orijinal değer eşleşmezse 0 satır!

-- Etkilenen satır sayısı 0 ise → DbUpdateConcurrencyException

IsConcurrencyToken() kullanılırsa değer otomatik artmaz — uygulamanın kendisi yeni değer atamalı. IsRowVersion() ise SQL Server tarafından otomatik yönetilir.

21.4 SQL'de Tam Çalışma Mekanizması

-- EF'in SaveChanges() ile ürettiği sorgu (RowVersion):
UPDATE [Products]
SET [Name] = @p0, [Price] = @p1, [Stock] = @p2
WHERE [Id] = @p3 AND [RowVersion] = @p4;

SELECT [RowVersion]          -- Yeni RowVersion'ı geri al
FROM [Products]
WHERE @@ROWCOUNT = 1 AND [Id] = @p3;

-- @@ROWCOUNT = 0 ise → kimse etkilenmedi → DbUpdateConcurrencyException!
-- EF'in SaveChanges() ile ürettiği sorgu (xmin concurrency token):
UPDATE products
SET name = @p0, price = @p1, stock = @p2
WHERE id = @p3 AND xmin = @p4
RETURNING xmin;              -- Yeni xmin değerini geri al

-- Etkilenen satır 0 ise → DbUpdateConcurrencyException!
-- PostgreSQL'de xmin, her UPDATE'te otomatik değişen transaction ID'dir

Zaman Çizelgesi Senaryosu:

Zaman   Kullanıcı A                       Kullanıcı B
─────   ──────────────────────────        ──────────────────────────
T1      Product oku (RowVersion=0x01)      
T2                                         Product oku (RowVersion=0x01)
T3      Price=100→120, SaveChanges()       
        WHERE RowVersion=0x01 → 1 satır ✅  
        (RowVersion artık 0x02)
T4                                         Price=100→150, SaveChanges()
                                           WHERE RowVersion=0x01 → 0 satır ❌
                                           → DbUpdateConcurrencyException!

21.5 Conflict Resolution (Çakışma Çözümleme)

Strateji karşılaştırma:

Strateji Ne zaman? Veri kaybı riski
Client Wins Admin paneli, son güncelleme geçerli Orta — önceki değişiklik ezilir
Database Wins Okuma ağırlıklı, conflict nadir Düşük — kullanıcıya bilgi verilir
Merge Farklı alanlar güncelleniyorsa En düşük — alan bazlı birleştirme

Strateji 1: Client Wins (Son yazan kazanır)

User A Price=100 User B Price=200 Save ✓ DB: 100 v2 Save ✗ Concurrency Exception! Retry OriginalValues = DB → Save (Price=200) DB: Price=200 ✓ User B kazandı
// Strateji 1: Client Wins (Son yazan kazanır — force overwrite)
public async Task UpdateProductClientWins(int productId, decimal newPrice)
{
    var product = await _context.Products.FindAsync(productId);
    product.Price = newPrice;

    bool saved = false;
    while (!saved)
    {
        try
        {
            await _context.SaveChangesAsync();
            saved = true;
        }
        catch (DbUpdateConcurrencyException ex)
        {
            foreach (var entry in ex.Entries)
            {
                // DB'deki güncel değerleri al
                var dbValues = await entry.GetDatabaseValuesAsync();
                if (dbValues == null)
                    throw new InvalidOperationException("Kayıt silinmiş!");

                // Original values'ı DB'deki ile değiştir → bir sonraki WHERE geçer
                entry.OriginalValues.SetValues(dbValues);
            }
        }
    }
}

Strateji 2: Database Wins (DB'deki değer korunur)

User A Price=100 ✓ DB: 100 v2 User B Price=200 ✗ Exception! Reload entry.ReloadAsync() Değişiklik iptal DB: Price=100 ✓ User A kazandı
// Strateji 2: Database Wins (DB'deki değer korunur — discard changes)
public async Task UpdateProductDatabaseWins(int productId, decimal newPrice)
{
    var product = await _context.Products.FindAsync(productId);
    product.Price = newPrice;

    try
    {
        await _context.SaveChangesAsync();
    }
    catch (DbUpdateConcurrencyException ex)
    {
        foreach (var entry in ex.Entries)
        {
            // DB'deki değerleri hem Original hem Current'a yaz → değişiklik iptal
            await entry.ReloadAsync();
        }
        // İsterseniz kullanıcıya "Başkası güncelledi, tekrar deneyin" mesajı gösterin
    }
}

Strateji 3: Merge (Alan bazlı birleştirme)

User A saves Name="Laptop Pro" Price değişmedi User B wants Price=25000 Name değişmedi Conflict! v1 ≠ v2 Merge Logic A değiştirdi? → A kalsın B değiştirdi? → B kalsın İkisi de? → Proposed kazanır DB: Name="Laptop Pro", Price=25000 Her iki değişiklik de korundu ✓
// Strateji 3: Merge (Alan bazlı birleştirme — en gelişmiş)
public async Task UpdateProductMerge(int productId, decimal newPrice, string newName)
{
    var product = await _context.Products.FindAsync(productId);
    product.Price = newPrice;
    product.Name = newName;

    try
    {
        await _context.SaveChangesAsync();
    }
    catch (DbUpdateConcurrencyException ex)
    {
        foreach (var entry in ex.Entries)
        {
            var proposedValues = entry.CurrentValues;       // Bizim yazmak istediğimiz
            var originalValues = entry.OriginalValues;      // İlk okuduğumuz
            var dbValues = await entry.GetDatabaseValuesAsync(); // DB'deki güncel

            foreach (var property in proposedValues.Properties)
            {
                var proposed = proposedValues[property];
                var original = originalValues[property];
                var database = dbValues[property];

                // Karar mantığı: Hangi değer geçerli?
                if (original != database && original == proposed)
                {
                    // Biz değiştirmedik ama DB'de değişmiş → DB'deki kalsın
                    proposedValues[property] = database;
                }
                // else: Biz değiştirdik → bizimki geçerli (proposedValues zaten set)
            }

            // Original'ı güncelle ki WHERE doğru çalışsın
            entry.OriginalValues.SetValues(dbValues);
        }

        await _context.SaveChangesAsync(); // Tekrar dene (merge edilmiş değerlerle)
    }
}

21.6 Retry Pattern (Tekrar Deneme)

public async Task<bool> UpdateWithRetry(int productId, decimal newPrice, int maxRetries = 3)
{
    for (int attempt = 0; attempt < maxRetries; attempt++)
    {
        try
        {
            // Her denemede TAZE veri oku (yeni DbContext veya reload)
            var product = await _context.Products.FindAsync(productId);
            if (product == null) return false;

            // Değişikliği uygula
            product.Price = newPrice;
            await _context.SaveChangesAsync();
            return true;
        }
        catch (DbUpdateConcurrencyException)
        {
            // Son deneme ise fırlat
            if (attempt == maxRetries - 1) throw;

            // Entry'leri detach et ve tekrar dene
            foreach (var entry in _context.ChangeTracker.Entries())
                entry.State = EntityState.Detached;
        }
    }
    return false;
}

21.7 Disconnected Scenario (Web API)

Web API'de okuma ve güncelleme farklı request'lerde gerçekleşir. RowVersion değerini client'a gönderip geri alman gerekir.

// DTO
public class ProductUpdateDto
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public byte[] RowVersion { get; set; }  // Client bunu geri gönderir
}

// Controller
[HttpPut("{id}")]
public async Task<IActionResult> UpdateProduct(int id, ProductUpdateDto dto)
{
    var product = await _context.Products.FindAsync(id);
    if (product == null) return NotFound();

    // Değerleri güncelle
    product.Name = dto.Name;
    product.Price = dto.Price;

    // Client'ın bildiği RowVersion'ı OriginalValue olarak set et
    _context.Entry(product).Property(p => p.RowVersion)
            .OriginalValue = dto.RowVersion;

    try
    {
        await _context.SaveChangesAsync();
        return Ok(new { product.Id, product.RowVersion }); // Yeni RowVersion'ı döndür
    }
    catch (DbUpdateConcurrencyException)
    {
        return Conflict(new
        {
            Message = "Kayıt başka biri tarafından güncellenmiş.",
            CurrentRowVersion = (await _context.Products.FindAsync(id))?.RowVersion
        });
    }
}

HTTP Akışı:

GET /api/products/1
← { id:1, name:"Laptop", price:100, rowVersion:"AAAAAA==" }

PUT /api/products/1
→ { id:1, name:"Laptop", price:120, rowVersion:"AAAAAA==" }

Başarılı: ← 200 { id:1, rowVersion:"AAAAAB==" }
Çakışma:  ← 409 { message:"Kayıt başka biri tarafından güncellenmiş." }

21.8 PostgreSQL & Diğer Provider'larda Concurrency

// PostgreSQL: xmin system column (satır sürümü)
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        // PostgreSQL'de xmin kullanımı (Npgsql provider)
        builder.UseXminAsConcurrencyToken();

        // Property olarak tanımlamaya gerek yok — xmin gizli sütundur
    }
}

// SQLite: RowVersion yok — manuel GUID/DateTime token kullanılır
public class SqliteProductConfiguration : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        builder.Property(p => p.ConcurrencyStamp)
               .IsConcurrencyToken()
               .HasDefaultValueSql("hex(randomblob(8))");  // Benzersiz değer

        // SaveChanges override ile her güncellemede yeni stamp:
    }
}

Provider karşılaştırma:

Provider Yöntem Otomatik? Not
SQL Server ROWVERSION / TIMESTAMP 8-byte, her UPDATE'te artar
PostgreSQL xmin system column UseXminAsConcurrencyToken()
SQLite Manuel (GUID/DateTime) Uygulama yönetir
MySQL Manuel (TIMESTAMP column) ON UPDATE CURRENT_TIMESTAMP

21.9 Concurrency Token + Owned Types & İlişkiler

// Owned Type içinde concurrency token OLMAZ — parent entity'de tanımlanır
public class Order
{
    public int Id { get; set; }
    public Address ShippingAddress { get; set; }  // Owned
    public byte[] RowVersion { get; set; }        // Token burada!
}

// İlişkili entity'lerde: HER entity kendi token'ına sahip olmalı
public class OrderItem
{
    public int Id { get; set; }
    public int OrderId { get; set; }
    public decimal UnitPrice { get; set; }
    public byte[] RowVersion { get; set; }  // OrderItem'ın kendi token'ı
}

Navigation property üzerinden güncelleme yapılırsa (ör. order.Items[0].UnitPrice = 50), EF ilgili entity'nin RowVersion'ını kontrol eder — parent'ın değil.

21.10 Sık Yapılan Hatalar

Hata Sonuç Çözüm
RowVersion'ı [NotMapped] yapmak Concurrency kontrolü çalışmaz Property'yi map'le
AsNoTracking() ile okuyup güncelleme OriginalValues kaybolur Tracked oku veya OriginalValue'yu manuel set et
RowVersion'ı DTO'da göndermemek Disconnected'da kontrol çalışmaz DTO'ya ekle, client geri göndersin
try/catch olmadan SaveChanges Uygulama çöker Her zaman exception handle et
Tek DbContext'te sonsuz retry Stale entry'ler birikir Her retry'da Detach veya yeni context