Mert Özen Her Satırda Daha İleri
Backend Message Queue'larla Çalışmak — #09

Dead-Letter Queue Yönetimi: Poison Message'ı Bulmak, İncelemek ve Yeniden İşlemek

Mert Özen 8 Kas 2026 12 dk 4 görüntülenme
Dead-Letter Queue Yönetimi: Poison Message'ı Bulmak, İncelemek ve Yeniden İşlemek

DLQ'yu bir iş listesi gibi yönet: mesajın neden düştüğünü header'lardan oku, büyümesini alarma bağla, düzeltmeden sonra dry-run, filtre, üst sınır ve kendi replay sayacınla geri gönder. RabbitMQ'da x-death'in client'ın geri yayınladığı mesajda ilerlemediği, Service Bus DLQ sebep kodları, Kafka DLT'de yanlış orijinal topic hatası ve MassTransit _error header'ları gerçek çıktılarla.

Sürüm notu: Bu yazıdaki kodlar .NET 10, RabbitMQ 4.4, RabbitMQ.Client 7.2.2, Azure.Messaging.ServiceBus 7.21 (Service Bus emulator), Confluent.Kafka 2.16 (Kafka 4.2) ve MassTransit 8.5.12 ile derlenip denenmiştir.

Önce Tahmin Et

Billing'in DLQ'sunda (Dead-Letter Queue) bir mesaj var; RabbitMQ'nun eklediği x-death header'ı "billing.order-placed'ten 1 kez reject edildi" diyor. Hatayı düzelttiğini sanıp mesajı asıl queue'ya geri yayınlıyorsun, ama düzeltme eksik kalmış ve mesaj ikinci kez reject edilip DLQ'ya dönüyor. Şimdi x-death'teki sayaç kaçı gösterir: 1 mi, 2 mi? Replay aracın "bu mesajı üç kez denedim, artık yeter" kararını bu sayaca bakarak verebilir mi?

Bir önceki yazıda retry'ı kurduk ve bazı mesajların yolun sonunda DLQ'ya düşeceğini gördük. Bu yazıda DLQ'yu bir çöp kutusu değil, bir iş listesi olarak ele alıyoruz: mesaj neden düştü, kim fark edecek, düzeltmeden sonra nasıl güvenle geri gönderilecek? Poison message ve dead-letter kavramlarının kendisi Messaging ve Event-Driven Architecture serisinde; burada üç broker'da ve MassTransit'te kod var.

RabbitMQ: DLX, Delivery Limit ve x-death

RabbitMQ'da DLQ, sıradan bir queue'dur; onu DLQ yapan, asıl queue'nun DLX (Dead Letter Exchange) ayarıdır. Bir mesaj dört durumda dead-letter edilir: requeue: false ile reject ya da nack, mesaj TTL'inin (Time-To-Live) dolması, queue uzunluk sınırının aşılması ve quorum queue'nun delivery limit'inin aşılması. Billing'in topology'si:

await channel.QueueDeclareAsync("billing.order-placed", durable: true, exclusive: false, autoDelete: false,
    arguments: new Dictionary<string, object?>
    {
        ["x-queue-type"] = "quorum",
        ["x-delivery-limit"] = 5,
        ["x-dead-letter-exchange"] = "bookstore.dlx",
        ["x-dead-letter-routing-key"] = "billing.order-placed",
        // at-least-once dead-lettering: DLQ'ya kopya onaylanmadan mesaj silinmez
        ["x-dead-letter-strategy"] = "at-least-once",
        ["x-overflow"] = "reject-publish"
    });
await channel.QueueBindAsync("billing.order-placed", "bookstore.ordering", "order.placed");

await channel.QueueDeclareAsync("billing.order-placed.dlq", durable: true, exclusive: false, autoDelete: false,
    arguments: new Dictionary<string, object?>
    {
        ["x-queue-type"] = "quorum",
        ["x-message-ttl"] = (int)TimeSpan.FromDays(14).TotalMilliseconds,
        ["x-max-length"] = 100_000
    });
await channel.QueueBindAsync("billing.order-placed.dlq", "bookstore.dlx", "billing.order-placed");

Varsayılan dead-lettering at-most-once çalışır: broker mesajı DLX'e iç publisher confirm olmadan yollar ve hedef queue o an erişilemezse mesaj kaybolur. Quorum queue'larda x-dead-letter-strategy = at-least-once ile bu kapanır; RabbitMQ dokümanına göre bunun için x-overflow'un reject-publish olması gerekir, varsayılan drop-head ile strateji sessizce at-most-once'a düşer. DLQ'nun kendisine de iki sınır koyduk: 14 günlük TTL ve 100.000 mesajlık üst sınır. Neden olduklarını "Üretimde Dikkat" bölümünde konuşacağız.

Üç mesaj yayınladım. Consumer msg-1 ve msg-3'ü kalıcı hata sayıp requeue: false ile reject ediyor, msg-2'yi ise her seferinde requeue: true ile geri bırakıyor. DLQ'yu okuyan küçük bir yardımcı, header'ları şöyle özetliyor:

msg-1: first-death=rejected@billing.order-placed deaths=[billing.order-placed/rejected x1] replay-count=0
msg-3: first-death=rejected@billing.order-placed deaths=[billing.order-placed/rejected x1] replay-count=0
msg-2: first-death=delivery_limit@billing.order-placed deaths=[billing.order-placed/delivery_limit x1] replay-count=0

Broker ilk dead-lettering'de altı header ekler: x-first-death-queue, x-first-death-reason, x-first-death-exchange ve bunların x-last-death-* karşılıkları. x-death ise en yenisi başta olacak şekilde bir dizi; her element'te queue, reason, count, time, exchange ve routing-keys var. Reason dört değerden biri: rejected, expired, maxlen, delivery_limit. Bu ayrım replay kararının temeli: rejected "consumer bu mesajı bilerek reddetti", delivery_limit ise "consumer bu mesajla defalarca boğuştu ve bir sonuca varamadı" demek. İkincisi çoğu zaman consumer'ın kendisinde bir hata ya da process'i çökerten bir mesaj olduğunu gösterir.

Header'ları .NET'te okurken bir tuzak var: AMQP (Advanced Message Queuing Protocol) 0-9-1 header'larındaki string'ler client'a string değil byte[] olarak gelir:

public sealed record DeadLetterInfo(
    string MessageId, string? FirstDeathReason, string? FirstDeathQueue, IReadOnlyList<string> Deaths, int ReplayCount)
{
    public static DeadLetterInfo From(IReadOnlyBasicProperties props)
    {
        IDictionary<string, object?> headers = props.Headers ?? new Dictionary<string, object?>();
        var deaths = new List<string>();
        if (headers.TryGetValue("x-death", out object? raw) && raw is IList list)
        {
            foreach (IDictionary<string, object?> death in list.OfType<IDictionary<string, object?>>())
                deaths.Add($"{Text(death["queue"])}/{Text(death["reason"])} x{death["count"]}");
        }
        int replayCount = headers.TryGetValue("x-replay-count", out object? rc) && rc is int n ? n : 0;
        return new DeadLetterInfo(props.MessageId ?? "?", Text(Get(headers, "x-first-death-reason")),
            Text(Get(headers, "x-first-death-queue")), deaths, replayCount);
    }

    private static object? Get(IDictionary<string, object?> headers, string key) =>
        headers.TryGetValue(key, out object? value) ? value : null;

    // AMQP 0-9-1 header'larındaki string'ler .NET client'a byte[] olarak gelir
    private static string? Text(object? value) => value switch
    {
        byte[] bytes => Encoding.UTF8.GetString(bytes),
        null => null,
        _ => value.ToString()
    };
}

Azure Service Bus: Her Entity'nin Kendi DLQ'su

Service Bus'ta DLQ'yu sen oluşturmazsın; her queue ve her subscription'ın kendine ait bir dead-letter sub-queue'su vardır ve ayrıca silinemez. Sistem kendi sebep kodlarını yazar (MaxDeliveryCountExceeded, TTLExpiredException, HeaderSizeExceeded, Session ID is null, MaxTransferHopCountExceeded), ama kalıcı hatayı fark eden consumer'ın mesajı kendi sebebiyle göndermesi çok daha okunur bir iz bırakır:

ServiceBusReceiver receiver = client.CreateReceiver("orders");
ServiceBusReceivedMessage message = (await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(5)))!;
await receiver.DeadLetterMessageAsync(message,
    deadLetterReason: "UnsupportedCurrency",
    deadLetterErrorDescription: "Currency XXX is not supported by the payment provider");

DLQ'yu okumak için ayrı bir path kurmazsın; receiver'ı SubQueue.DeadLetter ile açarsın:

static async Task PrintDeadLettersAsync(ServiceBusClient client)
{
    ServiceBusReceiver dlq = client.CreateReceiver("orders", new ServiceBusReceiverOptions { SubQueue = SubQueue.DeadLetter });
    IReadOnlyList<ServiceBusReceivedMessage> messages = await dlq.PeekMessagesAsync(maxMessages: 10);
    foreach (ServiceBusReceivedMessage m in messages)
    {
        Console.WriteLine($"DLQ {m.MessageId} DeliveryCount={m.DeliveryCount} DeadLetterSource={m.DeadLetterSource ?? "-"}");
        Console.WriteLine($"    DeadLetterReason={m.DeadLetterReason}");
        Console.WriteLine($"    DeadLetterErrorDescription={m.DeadLetterErrorDescription}");
    }
}

Emulator'de iki ayrı koşunun çıktısı; ilki önceki yazıdaki abandon döngüsünün sonu, ikincisi yukarıdaki elle dead-letter:

DLQ msg-A DeliveryCount=3 DeadLetterSource=-
    DeadLetterReason=MaxDeliveryCountExceeded
    DeadLetterErrorDescription=Message could not be consumed after 3 delivery attempts.

DLQ msg-A DeliveryCount=0 DeadLetterSource=-
    DeadLetterReason=UnsupportedCurrency
    DeadLetterErrorDescription=Currency XXX is not supported by the payment provider

PeekMessagesAsync mesajı kilitlemez ve silmez; inceleme aracı için doğru seçim budur. Microsoft'un önerisi, DeadLetterReason'a exception tipini, açıklamaya stack trace'i yazmak; yalnız Standard katmanda mesaj sınırı 256 KB olduğu için uzun bir stack trace mesajı bu sınırın üstüne taşıyabilir. İki önemli fark daha var: DLQ'da TTL uygulanmaz ve otomatik temizlik yoktur. Bir mesaj sen onu alıp complete edene kadar orada kalır. Yeniden göndermek için en kolay yol Azure portal'daki Service Bus Explorer; kodla yapacaksan DLQ'dan aldığın mesajı new ServiceBusMessage(received) ile kopyalayıp asıl entity'ye gönderir, sonra DLQ'daki mesajı complete edersin (bu replay kodunu emulator'de çalıştırmadım, aşağıdaki RabbitMQ aracıyla aynı sırayı izler).

Kafka: Dead-Letter Topic

Kafka'nın broker tarafında dead-letter kavramı yok. Bir önceki yazıdaki router, deneme hakkı bitince mesajı bookstore.orders.billing.dlt topic'ine yazıyordu. DLT'yi (dead-letter topic) okuyan bir inceleme aracı, commit etmeden baştan okuyup header'ları yazdırır:

var config = new ConsumerConfig
{
    BootstrapServers = "localhost:17311",
    GroupId = "billing-dlt-inspector",
    AutoOffsetReset = AutoOffsetReset.Earliest,
    EnableAutoCommit = false
};
using IConsumer<string, byte[]> consumer = new ConsumerBuilder<string, byte[]>(config).Build();
consumer.Subscribe("bookstore.orders.billing.dlt");
while (consumer.Consume(TimeSpan.FromSeconds(10)) is { } result)
{
    Console.WriteLine($"{result.TopicPartitionOffset} key={result.Message.Key[..8]}");
    foreach (IHeader header in result.Message.Headers)
        Console.WriteLine($"    {header.Key}: {Encoding.UTF8.GetString(header.GetValueBytes())}");
}
consumer.Close();

İlk denememde router, DLT'ye giderken dlt-original-topic diye bir header'a result.Topic'i yazıyordu. Çıktı şuydu:

bookstore.orders.billing.dlt [[2]] @0 key=a26ab9df
    message-id: msg-B
    retry-attempt: 3
    dlt-original-topic: bookstore.orders.billing.retry
    dlt-exception-type: System.Net.Http.HttpRequestException
    dlt-exception-message: 503 Service Unavailable from payment provider

Hata belli: mesaj DLT'ye retry topic'inden geçerek geldiği için "orijinal" topic olarak retry topic'i yazılmış. Asıl topic'teki konumu ilk hata anında yakalamak gerekiyordu; router'ın son hâli bunu attempt == 1 olduğunda original-topic-partition-offset header'ına yazıyor:

bookstore.orders.billing.dlt [[2]] @0 key=8dbe442d
    message-id: msg-B
    original-topic-partition-offset: bookstore.orders [[2]] @0
    retry-attempt: 3
    dlt-exception-type: System.Net.Http.HttpRequestException
    dlt-exception-message: 503 Service Unavailable from payment provider

Kafka'da DLT'nin ne kadar tutulacağı topic'in retention.ms ayarıdır; bunu bilinçli ver. DLT'yi asıl topic'le aynı key ile yazmak da önemli: replay ederken aynı siparişin mesajları yine aynı partition'a düşer ve kendi aralarındaki sıra korunur.

MassTransit: _error ve _skipped

MassTransit, retry'lar tükenince mesajı endpoint adının sonuna _error eklenmiş bir queue'ya taşır ve hatayı header'lara yazar. Bir önceki yazıdaki XXX siparişinin billing-order-placed_error'daki hâli (stack trace'i kısalttım, bazı MT-Host-* satırlarını çıkardım):

MessageId=6c0a0000-0000-0000-43d4-08df3d3c3457 ContentType=application/vnd.masstransit+json
MT-Fault-ConsumerType: Bookstore.Billing.OrderPlacedConsumer
MT-Fault-ExceptionType: System.InvalidOperationException
MT-Fault-InputAddress: rabbitmq://localhost:17301/billing-order-placed
MT-Fault-Message: Unsupported currency XXX
MT-Fault-MessageType: Bookstore.Contracts.OrderPlaced
MT-Fault-StackTrace:    at Bookstore.Billing.PaymentGateway.ChargeAsync(OrderPlaced order, CancellationToken cancellationToken) ...
MT-Fault-Timestamp: 2026-11-08T02:01:29.5773530Z
MT-Host-Assembly: Lab08Mt
MT-Host-MassTransitVersion: 8.5.12.0
MT-Host-ProcessName: Lab08Mt
MT-Reason: fault

Bu header'lar replay aracı için hazır bir sınıflandırma: MT-Fault-ExceptionType ile filtreler, MT-Fault-InputAddress ile mesajı nereye geri göndereceğini bilirsin. MassTransit ayrıca her fault için bir Fault<OrderPlaced> event'i yayınlar; bunu dinleyen bir consumer, DLQ'ya bakmayı beklemeden bir destek kaydı açabilir. İkinci queue _skipped: endpoint'e gelen ama karşılayan bir consumer'ı olmayan mesajlar oraya gider. _skipped'de mesaj görmek çoğunlukla bir hata değil, bir topology ya da deploy sırası sorunudur (ör. yeni mesaj tipi, consumer'ı yayına girmeden yayınlanmış).

DLQ'yu İzlemek

Kimsenin bakmadığı bir DLQ, mesajın sessizce kaybolmasından sadece biraz daha iyidir. En basit sinyal, ASP.NET Core health check'i:

public sealed class DlqHealthCheck(IConnection connection, string dlq, uint warnAt) : IHealthCheck
{
    public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken ct = default)
    {
        await using IChannel channel = await connection.CreateChannelAsync(cancellationToken: ct);
        QueueDeclareOk ok = await channel.QueueDeclarePassiveAsync(dlq, ct);
        var data = new Dictionary<string, object> { ["queue"] = dlq, ["messages"] = ok.MessageCount };
        return ok.MessageCount >= warnAt
            ? HealthCheckResult.Degraded($"{dlq} has {ok.MessageCount} dead-lettered messages", data: data)
            : HealthCheckResult.Healthy(data: data);
    }
}
Degraded: billing.order-placed.dlq has 3 dead-lettered messages

Unhealthy değil Degraded döndürmesi bilinçli: DLQ'da mesaj olması servisin trafiği alamadığı anlamına gelmez ve load balancer'ın instance'ı devreden çıkarmasını istemezsin. QueueDeclarePassiveAsync'in verdiği sayı yalnız hazır (ready) mesajlardır; bir inceleme aracı o an mesajları tutuyorsa sayı düşük görünür. Üretimde asıl alarm metric'ten gelmeli: RabbitMQ'nun Prometheus plugin'i (4.4 management image'ında açık geliyor) queue bazında mesaj sayılarını da yayınlayabilir, Service Bus'ta ServiceBusAdministrationClient.GetQueueRuntimePropertiesAsync'in döndürdüğü DeadLetterMessageCount ya da Azure Monitor metric'leri, Kafka'da DLT'nin büyüme hızı kullanılır. Alarmı "sıfırdan büyük" yerine "son 15 dakikada artış" gibi bir hıza bağlamak, eski ve bilinen mesajların her gün aynı alarmı çaldırmasını önler. Metric toplama tarafı serinin on ikinci yazısında.

Güvenli Replay Aracı

Replay, DLQ'daki mesajı düzeltmeden sonra asıl queue'ya geri göndermektir ve production'da elle yapılan en riskli işlerden biridir. RabbitMQ yönetim arayüzündeki "move messages" gibi toplu araçlar her şeyi filtresiz taşır. Kendi aracımızın kuralları: önce dry-run, sebebe göre filtre, üst sınır, MessageId'yi koru, kaç kez replay edildiğini say, broker onaylamadan DLQ'dan silme ve exchange'e değil, mesajın düştüğü queue'ya doğrudan gönder:

public sealed class DlqReplayer(IChannel channel)
{
    public async Task<ReplayResult> ReplayAsync(
        string dlq, Func<DeadLetterInfo, bool> filter, int maxMessages, bool dryRun, CancellationToken ct = default)
    {
        int replayed = 0, skipped = 0;
        var heldBack = new List<ulong>();
        for (int i = 0; i < maxMessages; i++)
        {
            BasicGetResult? result = await channel.BasicGetAsync(dlq, autoAck: false, ct);
            if (result is null) break;

            DeadLetterInfo info = DeadLetterInfo.From(result.BasicProperties);
            if (dryRun || !filter(info) || info.FirstDeathQueue is null)
            {
                Console.WriteLine($"  {(dryRun && filter(info) ? "would replay" : "skip")} {info.MessageId} ({info.FirstDeathReason})");
                heldBack.Add(result.DeliveryTag);
                skipped++;
                continue;
            }

            var props = new BasicProperties(result.BasicProperties)
            {
                Headers = new Dictionary<string, object?>(result.BasicProperties.Headers!)
                {
                    ["x-replay-count"] = info.ReplayCount + 1,
                    ["x-replayed-at"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
                }
            };
            // MessageId aynı kalır: consumer idempotent ise ikinci işleme zarar vermez
            await channel.BasicPublishAsync("", info.FirstDeathQueue, mandatory: true, props, result.Body, ct);
            await channel.BasicAckAsync(result.DeliveryTag, multiple: false, ct);
            Console.WriteLine($"  replayed {info.MessageId} -> {info.FirstDeathQueue}");
            replayed++;
        }
        // atlananları DLQ'ya geri bırak (requeue)
        foreach (ulong tag in heldBack)
            await channel.BasicNackAsync(tag, multiple: false, requeue: true, ct);
        return new ReplayResult(replayed, skipped);
    }
}

Çağıran taraf filtreyi açıkça yazıyor:

var replayer = new DlqReplayer(channel);
ReplayResult result = await replayer.ReplayAsync("billing.order-placed.dlq",
    // yalnız reject edilenler ve en fazla 3 replay: aynı mesajı sonsuza kadar geri yollama
    filter: m => m.FirstDeathReason == "rejected" && m.ReplayCount < 3,
    maxMessages: 100, dryRun: mode == "replay-dry");
Console.WriteLine($"replayed={result.Replayed} skipped={result.Skipped} dryRun={mode == "replay-dry"}");

Önce dry-run, sonra gerçek replay:

== replay-dry
  would replay msg-3 (rejected)
  skip msg-2 (delivery_limit)
  would replay msg-1 (rejected)
replayed=0 skipped=3 dryRun=True
== replay
  replayed msg-3 -> billing.order-placed
  skip msg-2 (delivery_limit)
  replayed msg-1 -> billing.order-placed
replayed=2 skipped=1 dryRun=False

msg-2 bilinçli olarak geride kaldı: delivery_limit ile düşen bir mesaj, consumer'ın kendisi düzeltilmeden geri gönderilirse aynı döngüye tekrar girer. Şimdi baştaki soru. Düzeltmenin eksik kaldığını varsayalım; consumer replay edilen iki mesajı yine reject ediyor:

msg-2: first-death=delivery_limit@billing.order-placed deaths=[billing.order-placed/delivery_limit x1] replay-count=0
msg-3: first-death=rejected@billing.order-placed deaths=[billing.order-placed/rejected x1] replay-count=1
msg-1: first-death=rejected@billing.order-placed deaths=[billing.order-placed/rejected x1] replay-count=1

Cevap 1. Mesaj ikinci kez reject edildi ama x-death'teki count hâlâ x1. RabbitMQ 3.13'ten beri x- ile başlayan header'lar broker'a ait sayılıyor ve client'ın geri yayınladığı x-death sayacı ilerletmiyor; RabbitMQ'nun GitHub'daki ilgili tartışması da tam bu senaryo için "kendi sayaç header'ını kullan" öneriyor. Bizim x-replay-count'umuz ise 1'i gösteriyor ve filtre üçüncü replay'den sonra mesajı DLQ'da tutacak. Replay kararını her zaman kendi sayacına bağla.

Aracın bir sınırını da bilmek gerekiyor: replay, mesajı queue'nun sonuna ekler. Aynı siparişin daha yeni mesajları o arada işlenmişse, replay edilen eski mesaj onları ezebilir. Bu yüzden consumer'ın idempotent olması ve sipariş durumunu "eskiyi yeniye yazdırmayacak" şekilde kontrol etmesi gerekiyor; bunu bir sonraki yazıda kuruyoruz.

Üretimde Dikkat

DLQ kişisel veri tutar. OrderPlaced bir müşteri kimliği, belki bir e-posta adresi taşıyor; DLQ'da süresiz bekleyen mesaj, KVKK (Kişisel Verilerin Korunması Kanunu) ve GDPR (General Data Protection Regulation) açısından tanımsız bir saklama süresi demek. RabbitMQ'da DLQ'ya x-message-ttl ver (biz 14 gün verdik) ve süresi dolanların nereye gittiğini bilinçli seç; Service Bus DLQ'sunda TTL uygulanmadığı için periyodik bir temizlik job'ı gerekir; Kafka'da DLT'nin retention.ms'i bu süreyi belirler.

Bir sahibi olsun. Her DLQ'nun bir sahibi (takım), bir alarmı ve bir runbook'u olmalı: hangi sebep kodunda ne yapılır, kim replay yetkisine sahiptir, replay öncesi hangi kontrol yapılır. Replay aracı production'da elle çalıştırılan bir komut olduğu için çıktısını (hangi MessageId, kim, ne zaman) bir yere kaydet.

Boyuta sınır koy. Sınırsız bir DLQ, uzun bir kesintide broker'ın diskini doldurabilir. RabbitMQ'da x-max-length, Service Bus'ta entity boyutu, Kafka'da retention bu tavanı belirler. Tavana ulaşıldığında ne olacağını (en eskiyi düşürmek mi, yeniyi reddetmek mi) önceden seç.

DLQ'dan okumak için yetki ayrı olsun. DLQ'daki mesajlara erişebilen bir operatör, normal akışta göremeyeceği veriyi görebilir. Replay aracına ve yönetim arayüzüne erişimi ayrı bir rol ile sınırla.

Sık Düşülen Tuzaklar

DLQ'yu kimsenin izlememesi. Alarm yoksa DLQ, hataların sessizce biriktiği bir arşive döner ve bir gün müşteri "faturam gelmedi" diye aradığında fark edilir.

Replay'de aynı hatayla tekrar düşmek. Sebep düzeltilmeden replay etmek, DLQ'yu boşaltıp birkaç saniye sonra tekrar doldurmaktır. Önce dry-run ve az sayıda mesajla dene, sonucu gör, sonra devam et.

Sayaç olarak x-death'e güvenmek. Gördüğün gibi client'ın geri yayınladığı mesajda sayaç ilerlemiyor; kendi header'ını kullan.

Exchange'e replay etmek. Mesajı bookstore.ordering'e geri yayınlarsan, hata almamış Notification gibi bütün diğer consumer'lar da onu ikinci kez alır. Replay'i yalnız mesajın düştüğü queue'ya yap.

Ack'i publish'ten önce vermek. DLQ'daki mesajı önce silip sonra yayınlarsan, arada çöken araç mesajı sonsuza kadar kaybettirir. Önce publish ve confirm, sonra ack.

delivery_limit ile düşen mesajı körlemesine replay etmek. Bu mesajlar çoğunlukla consumer'daki bir hatayı gösterir; consumer düzeltilmeden replay, aynı döngüyü baştan başlatır.

Küçük Bir Deneme

★☆☆ DlqReplayer'a mesajın Type property'sine göre filtre ekle ve yalnız bookstore.order-placed.v1 mesajlarını replay et. Dry-run çıktısında diğer tiplerin "skip" olarak göründüğünü kontrol et.

★★☆ DLQ'ya düşen her mesaj için ilk reason'a göre gruplanmış bir özet çıkaran bir komut yaz (rejected: 2, delivery_limit: 1 gibi) ve bunu bir minimal API (Application Programming Interface) endpoint'i olarak aç. Endpoint mesajları ack'lememeli; çalıştıktan sonra DLQ'daki sayının değişmediğini doğrula.

★★★ Service Bus için aynı replay aracını yaz: DLQ'dan ReceiveMessagesAsync ile al, DeadLetterReason'a göre filtrele, new ServiceBusMessage(received) ile kopyala, kendi replay sayacını application property'de taşı, gönder ve ancak gönderim başarılı olunca DLQ'daki mesajı complete et. Emulator'de dry-run ve gerçek replay'i dene.

Replay de retry de aynı gerçeğe çıkıyor: aynı mesaj bir kereden fazla gelebilir. Bir sonraki yazıda bunu kodda karşılıyoruz: MessageId ile bir inbox tablosu, iş verisiyle aynı transaction'da unique constraint, doğal idempotency ve MassTransit'in hazır inbox'ı; Billing'in aynı sipariş için iki fatura kesmemesi.