Idempotent Consumer: Aynı Message İki Kez Gelirse Ne Olur?
Aynı message'ın ikinci kez işlenmesini önlemenin güvenilir yolu, stabil bir dedup key'i (MessageId ya da iş anahtarı) iş verisiyle aynı transaction'da, consumer başına unique bir inbox tablosuna yazmak ve eşzamanlı duplicate'lerde kararı unique constraint'e bırakmaktır. Check-then-process failure window'u, commit öncesi çökme, iki eşzamanlı kopya ve transaction'a katılamayan e-posta için iki aşamalı kayıt EF Core 10 ve SQL Server 2025 üzerinde gerçek çıktılarla.
Sürüm notu: Bu yazıdaki kodlar C# 14, .NET 10 ve Entity Framework Core (EF Core) 10 ile derlenip, veritabanı tarafında Structured Query Language (SQL) Server 2025 üzerinde denenmiştir.
Önce Tahmin Et
Inventory, OrderPlaced geldiğinde siparişteki kitaplar için stok ayırıyor. İki önceki yazıda gördük: broker message'ı at-least-once teslim ediyor, yani aynı message iki kez gelebilir. Inventory'nin veritabanı ve consumer'ı şöyle:
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Inventory;
public sealed class StockItem
{
public Guid BookId { get; init; }
public required string Title { get; init; }
public int OnHand { get; set; }
public int Reserved { get; set; }
}
// Inbox: her consumer'ın işlediği message'ların kaydı
public sealed class InboxMessage
{
public required string MessageId { get; init; }
public required string Consumer { get; init; }
public DateTimeOffset ReceivedAt { get; init; }
public DateTimeOffset? CompletedAt { get; set; } // null: işleniyor (iki aşamalı kayıt)
}
public sealed class InventoryDbContext(DbContextOptions<InventoryDbContext> options) : DbContext(options)
{
public DbSet<StockItem> StockItems => Set<StockItem>();
public DbSet<InboxMessage> Inbox => Set<InboxMessage>();
protected override void OnModelCreating(ModelBuilder model)
{
model.Entity<StockItem>().HasKey(s => s.BookId);
model.Entity<InboxMessage>(inbox =>
{
// Hakem veritabanı: aynı consumer aynı message'ı ikinci kez yazamaz
inbox.HasKey(m => new { m.MessageId, m.Consumer });
inbox.Property(m => m.MessageId).HasMaxLength(100);
inbox.Property(m => m.Consumer).HasMaxLength(100);
});
}
}
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace Bookstore.Inventory;
public sealed record OrderPlacedMessage(string MessageId, Guid OrderId, Guid BookId, int Quantity);
// Her teslimde stok ayırır
public sealed class NaiveReserveStock(InventoryDbContext db)
{
public Task HandleAsync(OrderPlacedMessage m) =>
db.StockItems.Where(s => s.BookId == m.BookId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Reserved, x => x.Reserved + m.Quantity));
}
using System.Diagnostics;
using Bookstore.Inventory;
using Microsoft.EntityFrameworkCore;
string connectionString = Environment.GetEnvironmentVariable("BOOKSTORE_SQL")
?? "Server=localhost;Database=eda_06;Trusted_Connection=True;TrustServerCertificate=True";
DbContextOptions<InventoryDbContext> options = new DbContextOptionsBuilder<InventoryDbContext>()
.UseSqlServer(connectionString).Options;
TimeProvider clock = TimeProvider.System;
Guid dune = Guid.CreateVersion7();
await using (InventoryDbContext db = new(options))
{
await db.Database.EnsureDeletedAsync();
await db.Database.EnsureCreatedAsync();
}
async Task<int> ResetAsync()
{
await using InventoryDbContext db = new(options);
await db.Inbox.ExecuteDeleteAsync();
await db.StockItems.ExecuteDeleteAsync();
db.StockItems.Add(new StockItem { BookId = dune, Title = "Dune", OnHand = 10 });
return await db.SaveChangesAsync();
}
async Task<int> ReservedAsync()
{
await using InventoryDbContext db = new(options);
return await db.StockItems.Where(s => s.BookId == dune).Select(s => s.Reserved).SingleAsync();
}
OrderPlacedMessage message = new(MessageId: Guid.CreateVersion7().ToString(), OrderId: Guid.CreateVersion7(), BookId: dune, Quantity: 2);
Ada iki Dune sipariş etti; message bir kez işlendi, ack yolda kayboldu ve broker aynı message'ı yeniden teslim etti:
// Naive: aynı message iki kez
await ResetAsync();
for (int delivery = 0; delivery < 2; delivery++)
{
await using InventoryDbContext db = new(options);
await new NaiveReserveStock(db).HandleAsync(message);
}
Console.WriteLine($"[naive] delivered twice -> Dune reserved: {await ReservedAsync()}");
Dune için kaç nüsha ayrılmış görünür? Bunu düzeltmek için consumer'a "bu message'ı daha önce gördüm mü?" diye soran bir if eklemek yeter mi? İki consumer instance'ı aynı message'ın iki kopyasını aynı milisaniyede alırsa ne olur?
Duplicate'in Bedeli
[naive] delivered twice -> Dune reserved: 4
İki kitaplık sipariş, dört nüsha ayırdı. Depoda duran iki Dune artık satılamaz görünüyor; kampanya zirvesinde bu, "stokta yok" diye kaçırılan siparişler demek. Bir önceki yazıda gördüğümüz gibi stok farkı (Reserved + 2) commutative, ama idempotent değil: iki kez uygulanınca sonuç değişiyor.
Microsoft'un Azure Architecture Center'daki Idempotent Consumer pattern'i çözümü şöyle tarif ediyor: consumer, başarıyla işlediği message'ların kaydını tutar ve aynı message'ı yeniden gördüğünde atlar. Tarif kısa, ama dört tasarım kararı içeriyor: neyi anahtar yapacağız, kaydı nerede tutacağız, kaydı iş ile nasıl birlikte yazacağız ve aynı anda gelen iki kopyada kim kazanacak? Önce bir adım geri çekilelim.
Önce Doğal Idempotency
Bazı işlemler kendiliğinden idempotent: iş anahtarına göre yapılan bir upsert, mutlak değer yazan bir güncelleme ("durum = iptal", "stok = 8"), HTTP (Hypertext Transfer Protocol) PUT. Bunları kaç kez uygularsan uygula sonuç aynı. Microsoft'un rehberi de önce bunu öneriyor: dedup kaydını yalnızca doğal olarak idempotent hâle getirilemeyen işlemler için kullan.
Inventory'deki sorun, işlemi fark olarak yazmamız. Domain-Driven Design serisindeki modelde stok ayırma StockItem.TryReserve(OrderId, qty) idi ve sipariş id'sine göre idempotentti: rezervasyon, sipariş başına bir kayıt olarak tutuluyordu. Aynı fikri veritabanında söylersek, (OrderId, BookId) üzerinde unique olan bir StockReservations tablosu, dedup kaydını iş verisinin kendisine taşır: aynı siparişin ikinci rezervasyonu tabloya giremez. Microsoft'un rehberindeki iki seçenekten ikincisi bu, dedup'ı business entity'nin üstünde tutmak. Avantajı ayrı bir tablo olmaması; bedeli, dedup mantığının her message türü için ayrı ayrı tasarlanması.
Her işlem böyle bir doğal anahtara sahip değil. Bir e-posta, bir bildirim ya da birden fazla tabloya dokunan bir iş için genel bir mekanizma gerekiyor: ayrı bir dedup tablosu, yani inbox. Bu yazının geri kalanı onu tasarlıyor.
Dedup Key: Neyi Hatırlayacağız?
Anahtar, aynı mantıksal message'ı her yeniden teslimde aynı şekilde tanımlamalı. İki aday var: producer'ın verdiği message id ya da işlemi tanımlayan bir iş anahtarı (idempotency key). Serinin 02. yazısındaki zarfın MessageId'si birinci aday. Burada iki uyarı var.
Correlation id dedup key değildir. Correlation id bir iş akışını tanımlar ve o akıştaki bütün message'larda aynıdır; onu anahtar yaparsan aynı siparişin OrderPlaced'i işlendikten sonra OrderCancelled'i "duplicate" diye atılır. CloudEvents kullanıyorsan spesifikasyonun tanımına göre bir event'i tekil olarak tanımlayan source ile id'nin birleşimidir.
Broker'ın ürettiği değerler de dedup key değildir. RabbitMQ'nun delivery tag'i, Azure Service Bus'ın lock token'ı, Kafka'daki teslim zamanı her yeniden teslimde değişir. Serinin 04. yazısındaki Redelivered bayrağı da yetmez: producer'ın tekrar gönderdiği kopya broker'a ayrı bir message olarak girer ve bayrak yanmaz. O yazının son alıştırmasındaki soru da buraya bağlanıyor: producer, MessageId'yi her gönderimde rastgele üretirse producer retry'ı iki farklı id'li iki message üretir ve hiçbir consumer onları eşleyemez. Id, gönderimden önce bir kez üretilip saklanmalı (serinin outbox yazısında bunu yapacağız) ya da işten türetilmeli.
Check-Then-Process: Failure Window
İlk akla gelen çözüm, işlemeden önce bakmak ve işledikten sonra kaydetmek:
// Önce kontrol et, sonra işle, sonra kaydet: üç ayrı adım
public sealed class CheckThenProcessReserveStock(InventoryDbContext db, TimeProvider clock)
{
public const string Name = "inventory.reserve-stock";
public Func<Task> AfterStockCommitted { get; init; } = () => Task.CompletedTask;
public async Task HandleAsync(OrderPlacedMessage m)
{
if (await db.Inbox.AnyAsync(x => x.MessageId == m.MessageId && x.Consumer == Name)) return;
await db.StockItems.Where(s => s.BookId == m.BookId) // kendi transaction'ında commit olur
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Reserved, x => x.Reserved + m.Quantity));
await AfterStockCommitted(); // process burada çökerse?
db.Inbox.Add(new() { MessageId = m.MessageId, Consumer = Name, ReceivedAt = clock.GetUtcNow(), CompletedAt = clock.GetUtcNow() });
await db.SaveChangesAsync();
}
}
// Check-then-process: stok commit oldu, dedup kaydı yazılmadan çöktü, message yeniden geldi
await ResetAsync();
try
{
await using InventoryDbContext db = new(options);
await new CheckThenProcessReserveStock(db, clock) { AfterStockCommitted = () => throw new Exception("crash") }
.HandleAsync(message);
}
catch (Exception) { }
await using (InventoryDbContext db = new(options)) await new CheckThenProcessReserveStock(db, clock).HandleAsync(message);
Console.WriteLine($"[check-then-process] crash after stock commit, redelivered -> Dune reserved: {await ReservedAsync()}");
[check-then-process] crash after stock commit, redelivered -> Dune reserved: 4
Kontrol vardı, ama sonuç yine dört. Stok güncellemesi ExecuteUpdateAsync ile kendi transaction'ında commit oldu, process tam dedup kaydını yazacakken çöktü. Yeniden gelen message'ta kontrol "bu message'ı görmedim" dedi, çünkü kayıt yoktu. Microsoft'un rehberi bu boşluğa check-then-process failure window diyor: iş ile kaydın arasında kalan her çökme, kaydın yazılmamasına ve işin tekrarlanmasına yol açar. Sırayı tersine çevirmek (önce kayıt, sonra iş) boşluğu kapatmaz, yalnızca yönünü değiştirir: o zaman çökme işin hiç yapılmamasına, yani at-most-once'a döner.
Inbox: Kayıt ve İş Aynı Transaction'da
Boşluğu kapatmanın yolu, dedup kaydını ve iş verisini aynı transaction'da yazmak. İkisi birlikte commit olur ya da hiçbiri olmaz; arada çökecek bir an kalmaz:
// Inbox: dedup kaydı ve iş aynı transaction'da, key ilk iş olarak talep ediliyor
public sealed class IdempotentReserveStock(InventoryDbContext db, TimeProvider clock)
{
public const string Name = "inventory.reserve-stock";
public Func<Task> BeforeCommit { get; init; } = () => Task.CompletedTask;
public async Task<bool> HandleAsync(OrderPlacedMessage m, CancellationToken ct = default)
{
await using IDbContextTransaction tx = await db.Database.BeginTransactionAsync(ct);
try
{
DateTimeOffset now = clock.GetUtcNow();
db.Inbox.Add(new() { MessageId = m.MessageId, Consumer = Name, ReceivedAt = now, CompletedAt = now });
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (ex.InnerException is SqlException { Number: 2627 or 2601 })
{
return false; // duplicate: başka bir teslim bu message'ı zaten işledi
}
await db.StockItems.Where(s => s.BookId == m.BookId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Reserved, x => x.Reserved + m.Quantity), ct);
await BeforeCommit();
await tx.CommitAsync(ct); // ya ikisi birden ya hiçbiri
return true;
}
}
Sıra bilinçli: transaction açılır açılmaz ilk iş inbox satırını yazmak. Bu, anahtarı "talep etmek" demek; birazdan eşzamanlı duplicate'lerde neden önemli olduğunu göreceğiz. Satır yazılamazsa (primary key ihlali) message daha önce işlenmiş demektir ve consumer hiçbir şey yapmadan döner; broker'a da ack gönderilir, çünkü duplicate'i atlamak başarılı bir sonuçtur. İki teslim ve commit'ten önceki bir çökme:
// Inbox: iki teslim
await ResetAsync();
List<bool> results = [];
for (int delivery = 0; delivery < 2; delivery++)
{
await using InventoryDbContext db = new(options);
results.Add(await new IdempotentReserveStock(db, clock).HandleAsync(message));
}
Console.WriteLine($"[inbox] delivered twice -> processed: {string.Join(", ", results)}; Dune reserved: {await ReservedAsync()}");
[inbox] delivered twice -> processed: True, False; Dune reserved: 2
// Inbox: commit'ten önce çöktü, message yeniden geldi
await ResetAsync();
try
{
await using InventoryDbContext db = new(options);
await new IdempotentReserveStock(db, clock) { BeforeCommit = () => throw new Exception("crash") }.HandleAsync(message);
}
catch (Exception) { }
int afterCrash = await ReservedAsync();
await using (InventoryDbContext db = new(options)) await new IdempotentReserveStock(db, clock).HandleAsync(message);
Console.WriteLine($"[inbox] crash before commit -> reserved {afterCrash}; redelivered -> Dune reserved: {await ReservedAsync()}");
[inbox] crash before commit -> reserved 0; redelivered -> Dune reserved: 2
İkinci teslim duplicate olarak atlandı ve stok iki kaldı. Commit'ten önceki çökmede ise hem inbox satırı hem stok güncellemesi geri alındı: stok sıfır, kayıt yok. Yeniden gelen message temiz bir sayfayla işlendi. Rehberin ifadesiyle bu, Transactional Outbox'ın consumer tarafındaki eşi olan Inbox pattern'i. Burada ikisinin de aynı SQL Server veritabanında olması şart; inbox tablosu başka bir veritabanında ya da bir cache'te (önbellek) durursa aynı transaction'a giremez ve failure window geri gelir.
Eşzamanlı Duplicate'ler: Hakem Unique Constraint
Competing consumer'larla çalışırken aynı message'ın iki kopyası iki instance'a aynı anda gelebilir: örneğin lock süresi dolduğunda ilk instance hâlâ çalışırken ikincisi message'ı alır. İkisi de "bu message'ı gördüm mü?" diye baktığında cevap hayırdır, çünkü ikisi de henüz commit etmemiştir. Uygulama kodundaki bir kontrol bu yarışı çözemez. Çözüm, kararı veritabanına bırakmak:
// Inbox: aynı message'ın iki kopyası aynı anda iki consumer instance'ında
await ResetAsync();
async Task<(bool Processed, long Ms)> RunAsync(TimeSpan holdBeforeCommit)
{
Stopwatch watch = Stopwatch.StartNew();
await using InventoryDbContext db = new(options);
bool processed = await new IdempotentReserveStock(db, clock) { BeforeCommit = () => Task.Delay(holdBeforeCommit) }
.HandleAsync(message);
return (processed, watch.ElapsedMilliseconds);
}
Task<(bool Processed, long Ms)> first = RunAsync(TimeSpan.FromMilliseconds(500));
await Task.Delay(100); // ikinci kopya, ilki transaction'ın içindeyken geliyor
Task<(bool Processed, long Ms)> second = RunAsync(TimeSpan.Zero);
await Task.WhenAll(first, second);
Console.WriteLine($"[inbox] two copies at once -> processed: {first.Result.Processed}, {second.Result.Processed}; " +
$"Dune reserved: {await ReservedAsync()}; second waited for the first: {second.Result.Ms >= 300}");
[inbox] two copies at once -> processed: True, False; Dune reserved: 2; second waited for the first: True
İlk kopya inbox satırını yazdı ve commit'ten önce yarım saniye bekledi. İkinci kopyanın INSERT'i aynı primary key için bekledi; SQL Server ikinci transaction'ı birincinin sonucuna kadar blokladı. Birinci commit olunca ikincinin insert'i 2627 numaralı hatayla reddedildi, consumer bunu duplicate olarak yorumladı ve stok iki kaldı. Hatanın SQL Server'daki metni:
Msg 2627, Level 14, State 1
Violation of PRIMARY KEY constraint 'PK_Inbox'. Cannot insert duplicate key in object 'dbo.Inbox'. The duplicate key value is (m-1, c).
Anahtarı transaction'ın ilk adımında talep etmenin faydası burada: ikinci kopya stoğa dokunmadan durdu ve bekleme süresi boyunca başka hiçbir satırı kilitlemedi. Birinci transaction geri alınsaydı ikincinin insert'i başarılı olur ve message'ı o işlerdi. Duplicate'lerin sık olduğu bir sistemde exception maliyetinden kaçınmak için transaction'dan önce ucuz bir AnyAsync kontrolü eklenebilir, ama o yalnızca bir optimizasyon; doğruluğu sağlayan constraint. Aynı ilke cache tabanlı dedup için de geçerli: "var mı?" diye bakıp sonra "yaz" demek yerine set-if-absent gibi tek adımlı, atomik bir işlem kullanılmalı.
Transaction'a Katılamayan Yan Etkiler
Her yan etki veritabanı transaction'ına girmiyor. E-posta göndermek, ödeme sağlayıcısını çağırmak, başka bir sistemin API'sine (Application Programming Interface) yazmak commit'le birlikte geri alınamaz. Microsoft'un rehberi bunun için iki aşamalı bir kayıt öneriyor: önce anahtarı "işleniyor" olarak kaydet, dış işlemi yap, sonra "tamamlandı" olarak işaretle.
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Inventory;
public enum EmailOutcome { Sent, AlreadySent, NeedsReconciliation }
public sealed class EmailProvider
{
public List<string> Sent { get; } = [];
// Gerçek sağlayıcılarda bu id ile tekrar gönderim engellenebilir ya da sorgulanabilir
public Task SendAsync(string idempotencyKey, string text) { Sent.Add($"{idempotencyKey}: {text}"); return Task.CompletedTask; }
}
// Veritabanı transaction'ına katılamayan yan etki: iki aşamalı kayıt
public sealed class SendStockReservedEmail(InventoryDbContext db, EmailProvider provider, TimeProvider clock)
{
public const string Name = "notifications.stock-reserved-email";
public Func<Task> AfterSend { get; init; } = () => Task.CompletedTask;
public async Task<EmailOutcome> HandleAsync(OrderPlacedMessage m)
{
InboxMessage? entry = await db.Inbox.SingleOrDefaultAsync(x => x.MessageId == m.MessageId && x.Consumer == Name);
if (entry is { CompletedAt: not null }) return EmailOutcome.AlreadySent;
if (entry is not null) return EmailOutcome.NeedsReconciliation; // önceki deneme yarım kalmış olabilir
entry = new() { MessageId = m.MessageId, Consumer = Name, ReceivedAt = clock.GetUtcNow() };
db.Inbox.Add(entry);
await db.SaveChangesAsync(); // 1. aşama: "işleniyor"
await provider.SendAsync(m.MessageId, $"Order {m.OrderId}: {m.Quantity} book(s) reserved");
await AfterSend();
entry.CompletedAt = clock.GetUtcNow();
await db.SaveChangesAsync(); // 2. aşama: "tamamlandı"
return EmailOutcome.Sent;
}
}
// Dış yan etki: e-posta gönderildi, "tamamlandı" yazılmadan çöktü
await ResetAsync();
EmailProvider provider = new();
try
{
await using InventoryDbContext db = new(options);
await new SendStockReservedEmail(db, provider, clock) { AfterSend = () => throw new Exception("crash") }.HandleAsync(message);
}
catch (Exception) { }
EmailOutcome redelivered;
await using (InventoryDbContext db = new(options)) redelivered = await new SendStockReservedEmail(db, provider, clock).HandleAsync(message);
Console.WriteLine($"[email] crash after send, redelivered -> emails sent: {provider.Sent.Count}, second delivery: {redelivered}");
[email] crash after send, redelivered -> emails sent: 1, second delivery: NeedsReconciliation
E-posta gitti, "tamamlandı" yazılamadan process çöktü. Yeniden gelen message, inbox'ta "işleniyor" durumunda bir kayıt buldu ve e-postayı körlemesine tekrar göndermek yerine NeedsReconciliation döndü. Doğru cevap bu, çünkü consumer önceki denemenin hangi noktada kaldığını bilemiyor: e-posta gitmiş olabilir, gitmemiş olabilir, ya da başka bir instance şu an gönderiyor olabilir. Uzlaştırmanın (reconciliation) en iyi yolu, dış sistemin kendi idempotency desteği: birçok ödeme ve e-posta sağlayıcısı bir idempotency key kabul ediyor, aynı key ile gelen ikinci isteği tekrar işlemiyor ya da sonucunu sorgulatıyor. Çözüm Desenleri serisinin "Idempotency Key: Çift Tıklama ve Retry'da Aynı Siparişi İki Kez Oluşturmamak" yazısı bu mekanizmanın sunucu tarafını anlatıyordu. Rehberin bir önerisi daha var: consumer başka servisleri çağırıyorsa idempotency key'i onlara da iletmeli ki her katman kendi işini tekilleştirebilsin. Uzlaştırılamayan kayıtlar ise bir insanın bakacağı bir yere, örneğin bir dead-letter queue'ya gitmeli.
Saklama Süresi, Pub/Sub ve Broker'ın Dedup'ı
Inbox tablosu sonsuza kadar büyümemeli, ama erken de silinmemeli. Rehberin kuralı: bir kaydı, broker'ın orijinal message'ı yeniden teslim edebileceği süreden daha uzun tut. Bu süre broker'ın en fazla teslim denemesine, lock ya da visibility timeout süresine ve message'ın yaşam süresine bağlı. Sık unutulan kalem ise dead-letter queue: bir operatörün bir hafta sonra yeniden gönderdiği message da bir duplicate adayı. Bookstore'da dead-letter queue'lar en geç birkaç gün içinde işleniyorsa inbox kayıtlarını örneğin 14 gün tutup sonrasını gece çalışan bir ExecuteDeleteAsync ile silmek makul bir başlangıç.
Inbox'ın anahtarı neden (MessageId, Consumer)? Çünkü publish/subscribe'da her abone aynı message'ın kendi kopyasını alır. Inventory ve Notification aynı veritabanını ve aynı inbox tablosunu paylaşıyorsa, yalnızca message id'ye bakan bir anahtar ilk işleyen consumer'ın diğerlerini susturmasına yol açar: Inventory stoğu ayırır, Notification'ın e-postası "duplicate" diye atılır.
Son olarak broker'ın kendi dedup'ı. Azure Service Bus'ın duplicate detection'ı aynı MessageId ile gelen ikinci message'ı en fazla 7 günlük bir pencere içinde düşürüyor ve bu, producer'ın tekrar göndermesine karşı faydalı. Ama gönderim tarafında çalışıyor ve sınırlı bir pencereye sahip; consumer'ın aynı message'ı yeniden teslimde ikinci kez işlemesini engellemiyor. Microsoft'un rehberi de bunu açıkça söylüyor: platform özellikleri duplicate sayısını azaltmak içindir, idempotent consumer'ın yerini tutmaz. NServiceBus ve MassTransit gibi framework'ler bu pattern'i hazır sunuyor; inbox ve temizliğin tam EF Core implementasyonu ilerideki Backend › Message Queue'larla Çalışmak serisinin konusu.
Sık Düşülen Tuzaklar
Dedup kaydını cache'te, işi veritabanında tutmak
"Hızlı olsun" diye dedup anahtarlarını bir cache'e yazmak, kayıtla işi iki ayrı sisteme böler; check-then-process boşluğu geri gelir. Cache bir ön filtre olabilir, hakem olamaz.
Duplicate'i hata sanmak
Duplicate tespit edilince exception fırlatıp message'ı ack'lememek, broker'ın onu tekrar tekrar teslim etmesine ve sonunda dead-letter queue'ya düşmesine yol açar. Duplicate, başarıyla atlanmış bir message'dır: ack'le ve geç. Aynı id ile farklı içerik gelmesi ise başka bir şey; Microsoft'un rehberi bu durumda message'ı sessizce atmak yerine dead-letter queue'ya gönderip alarm üretmeyi öneriyor.
Handler'ın içinde ikinci bir SaveChanges ya da başka bir bağlantı
Inbox'ın garantisi tek bir transaction'a dayanıyor. Handler'ın içinden başka bir DbContext ile, başka bir bağlantıyla ya da transaction dışında çalışan bir işlemle yazmak, o yazmayı garantinin dışına çıkarır. Domain-Driven Design serisinin "Domain Event'ler: Raise, Topla, SaveChanges'te Dispatch Et" yazısında iç içe SaveChanges'ın tek transaction vaadini nasıl bozduğunu görmüştük; burada da aynı tuzak var.
Rastgele MessageId
Producer her retry'da yeni bir id üretirse, consumer tarafındaki bütün bu mekanizma producer duplicate'lerine karşı kördür. Id, message ilk oluşturulduğunda bir kez üretilmeli ve retry'larda aynı kalmalı.
Küçük Bir Deneme
★☆☆ Inbox anahtarını yalnızca MessageId yap ve aynı message'ı önce IdempotentReserveStock, sonra Name sabiti farklı olan ikinci bir consumer class'ı ile işle. İkinci consumer ne yapıyor?
★★☆ IdempotentReserveStock'ta inbox satırını stok güncellemesinden sonra yaz (anahtarı en son talep et). Eşzamanlı duplicate senaryosu yine doğru sonuç veriyor mu? İkinci kopya hangi satırda bekliyor ve neden bu sıralama daha fazla kilit tutuyor?
★★★ Inventory için (OrderId, BookId) üzerinde unique olan bir StockReservations tablosu ekle ve stok ayırmayı inbox kullanmadan, doğal olarak idempotent hâle getir. Aynı sipariş iki farklı MessageId ile (producer duplicate'i) gelirse inbox'lı ve inbox'sız çözümler nasıl davranıyor?
Inbox, consumer tarafındaki dual write'ı kapattı: dedup kaydı ve iş tek transaction'da. Producer tarafında ise aynı sorun hâlâ açık: Ordering siparişi veritabanına commit ediyor, sonra OrderPlaced'i broker'a yayınlıyor. Commit başarılı olup yayın başarısız olursa? Bir sonraki yazıda transactional outbox ile bu boşluğu kapatacağız.