Mert Özen Her Satırda Daha İleri
Backend Background Job'lar ve Scheduled Job'lar — #07

Background Job'larda Retry ve Idempotency: At-Least-Once Gerçeğiyle Yaşamak

Mert Özen 8 Kas 2026 12 dk 2 görüntülenme
Background Job'larda Retry ve Idempotency: At-Least-Once Gerçeğiyle Yaşamak

Background job en az bir kez, bazen iki kez çalışır; retry bunu daha da olası kılar. Hangfire varsayılan olarak 10 kez artan aralıklarla dener, kalıcı hatalar ExceptOn ile retry'dan çıkarılır; Quartz 4'te retry policy açıkça tanımlanır. "Önce kontrol et, sonra gönder" iki eşzamanlı çalışmada iki e-posta üretir; unique key ile önce sahiplenmek tek e-postaya indirir, dış API'ye Idempotency-Key başlığı aynı işi uçtan uca yapar.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Hangfire 1.8.25, Quartz.NET 4.7.2, Entity Framework Core (EF Core) 10.0.14 ve SQL (Structured Query Language) Server 2025 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un fatura e-postası job'u şöyle başlıyor: "bu sipariş için fatura e-postası daha önce gönderilmiş mi? Gönderilmemişse gönder ve kaydet." Mantıklı görünüyor. Aynı job, bir worker çöktüğü ya da bir lock'un süresi dolduğu için iki worker'da aynı anda çalışırsa müşteri kaç e-posta alır? Kontrol veritabanında yapıldığına ve IdempotencyKey kolonunda unique bir index olduğuna göre, en fazla bir tane mi?

Neden En Az Bir Kez?

Bir job'un tam olarak bir kez çalışması, job altyapısının "bu iş bitti" bilgisini işin kendisiyle atomik olarak kaydetmesini gerektirir. Bu genellikle mümkün değil: e-posta sunucusu, ödeme sağlayıcısı ya da tedarikçi API'si (Application Programming Interface) senin veritabanı transaction'ına katılmaz. Bu yüzden job altyapıları iki seçenekten birini sunar: işi kaybetmeyi göze almak (en fazla bir kez) ya da tekrar çalıştırmayı göze almak (en az bir kez). Hangfire ve Quartz'ın RequestRecovery'si ikincisini seçer.

Çift çalışmanın kaynakları somut. Worker işin ortasında ölür; işi bir başkası baştan alır. Önceki yazıda Quartz cluster'ında 3. sayfanın recovering=True ile iki kez işlendiğini gördük. Worker ölmeden de olur: Hangfire'ın job'u "görünmez" tuttuğu süre ya da bir distributed lock'un lease süresi dolarsa iş hâlâ çalışırken bir başkası onu alabilir. Bir de retry var: iş dış servise isteği gönderip cevabı beklerken timeout alırsa, sonuç başarılı olsa bile job başarısız sayılır ve tekrar dener. Messaging ve Event-Driven Architecture serisinin "Delivery Guarantee'leri: At-Most-Once, At-Least-Once ve "Exactly-Once" Gerçeği" yazısı bu seçimi mesajlar için anlatıyordu; job'lar için de birebir aynı.

Hangfire'da Retry: AutomaticRetry

Hangfire'da her job, global bir filtre olarak kayıtlı AutomaticRetry ile korunuyor. Varsayılanları kendim yazdırdım: Attempts=10 OnAttemptsExceeded=Fail. 1.8.25'in kaynak kodundaki bekleme formülü, attempt deneme numarası olmak üzere saniye cinsinden (attempt - 1)^4 + 15 + random(0..29) * attempt: ilk tekrar 15–44 saniye sonra, onuncu tekrar 6561 saniyenin üzerinde, yani yaklaşık iki saat sonra. On denemenin toplamı yarım günü geçiyor. Beşinci yazıda yeniden adlandırılan method için gördüğümüz "Retry attempt 1 of 10 will be performed in 00:00:26" satırı bu formülün ilk adımıydı.

Varsayılanı job bazında değiştirebilirsin. Tedarikçi faturasını içeri alan bir job, ilk iki denemede timeout alıyor:

public sealed class PermanentJobException(string message) : Exception(message);

public sealed class SupplierInvoiceJobs(ILogger<SupplierInvoiceJobs> logger)
{
    private static int _attempts;

    // geçici hata: iki kez timeout, üçüncüde başarı
    [AutomaticRetry(Attempts = 3, DelaysInSeconds = [2, 5, 10])]
    public Task ImportAsync(int invoiceId)
    {
        int attempt = Interlocked.Increment(ref _attempts);
        logger.LogInformation("Importing supplier invoice {InvoiceId}, attempt {Attempt}", invoiceId, attempt);
        if (attempt < 3)
            throw new TimeoutException("Supplier API did not respond in 10 s");
        return Task.CompletedTask;
    }
}
05:11:18.845 info: Bookstore.Jobs.SupplierInvoiceJobs[0] Importing supplier invoice 7001, attempt 1
05:11:18.855 warn: Hangfire.AutomaticRetryAttribute[0] Failed to process the job '1': an exception occurred. Retry attempt 1 of 3 will be performed in 00:00:02. System.TimeoutException: Supplier API did not respond in 10 s [...]
05:11:20.777 info: Bookstore.Jobs.SupplierInvoiceJobs[0] Importing supplier invoice 7001, attempt 2
05:11:20.780 warn: Hangfire.AutomaticRetryAttribute[0] Failed to process the job '1': an exception occurred. Retry attempt 2 of 3 will be performed in 00:00:05. System.TimeoutException: Supplier API did not respond in 10 s [...]
05:11:25.815 info: Bookstore.Jobs.SupplierInvoiceJobs[0] Importing supplier invoice 7001, attempt 3

İki geçici hata, 2 ve 5 saniyelik beklemeler, üçüncü denemede başarı. Job'un State tablosundaki geçmişi her denemeyi saklıyor:

1|Enqueued|NULL|2026-11-08 02:11:18.630
1|Processing|NULL|2026-11-08 02:11:18.840
1|Failed|An exception occurred during performance of the job.|2026-11-08 02:11:18.857
1|Scheduled|Retry attempt 1 of 3: Supplier API did not respond in 10 s|2026-11-08 02:11:18.857
1|Enqueued|Triggered by DelayedJobScheduler|2026-11-08 02:11:20.753
1|Processing|NULL|2026-11-08 02:11:20.773
1|Failed|An exception occurred during performance of the job.|2026-11-08 02:11:20.780
1|Scheduled|Retry attempt 2 of 3: Supplier API did not respond in 10 s|2026-11-08 02:11:20.780
1|Enqueued|Triggered by DelayedJobScheduler|2026-11-08 02:11:25.800
1|Processing|NULL|2026-11-08 02:11:25.810
1|Succeeded|NULL|2026-11-08 02:11:25.820

Burada önemli bir ayrıntı var: her deneme job'u baştan çalıştırıyor. ImportAsync'in ilk denemesi faturanın yarısını içeri aktarıp sonra timeout aldıysa, ikinci deneme o yarıyı tekrar aktarır. Retry, idempotency'nin yerine geçmez; ona ihtiyaç duyar.

Kalıcı Hatayı Retry'dan Çıkarmak

Her hata tekrar denemeye değmez. "Bu faturanın eşleşen siparişi yok" hatası on deneme boyunca aynı kalacak; o on denemenin tek etkisi, insanın sorunu yarım gün sonra görmesi. Geçici (transient) hatalar için retry anlamlı: timeout, 503, deadlock, bağlantı kopması. Kalıcı hatalar için değil: doğrulama hatası, bulunamayan kayıt, yetkisiz istek, değişmiş method imzası.

// kalıcı hata: tekrar denemek sonucu değiştirmez
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [2, 5, 10], ExceptOn = [typeof(PermanentJobException)])]
public Task ReconcileAsync(int invoiceId)
{
    logger.LogInformation("Reconciling invoice {InvoiceId}", invoiceId);
    throw new PermanentJobException($"Invoice {invoiceId} has no matching order");
}

ExceptOn listesindeki exception'lar retry'a girmiyor. Gerçek koşuda job 2 doğrudan Failed'a geçti:

2|Enqueued|NULL|2026-11-08 02:11:18.650
2|Processing|NULL|2026-11-08 02:11:18.803
2|Failed|An exception occurred during performance of the job.|2026-11-08 02:11:18.813

Bunu bir sözleşmeye dönüştürmek işe yarar: domain kodun kalıcı hatalar için kendi exception tipini (burada PermanentJobException) fırlatsın, job filtresi de yalnız onu tanısın. Tersi de mümkün: OnlyOn yalnız listedeki exception'lar için retry yapar. Denemeler bitince ne olacağını OnAttemptsExceeded belirler; Fail job'u dashboard'da görünür bırakır, Delete siler. Kaybolmasını istemediğin bir iş için Delete kullanma.

Quartz'ta Retry Policy

Quartz'ta varsayılan olarak retry yok: bir job exception fırlattığında o çalışma başarısız sayılır ve trigger bir sonraki zamanını bekler. Eskiden tek araç JobExecutionException'ın refireImmediately bayrağıydı; job'u beklemeden, hemen tekrar çalıştırıyordu, yani backoff'u kendin yazman gerekiyordu. Quartz 4.0 ile retry policy'ler geldi:

[RetryPolicy(3, "00:00:02", 2.0)]   // 3 tekrar: 2 sn, 4 sn, 8 sn
public sealed class ExchangeRateImportJob(ILogger<ExchangeRateImportJob> logger) : IJob
{
    private static int _runs;

    public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken)
    {
        int run = Interlocked.Increment(ref _runs);
        logger.LogInformation("Exchange rate import, retry attempt {Attempt}", context.RetryAttempt);
        if (run < 3)
            throw new JobExecutionException(new HttpRequestException("Central bank API returned 503"));
        logger.LogInformation("Imported 34 exchange rates");
        return ValueTask.CompletedTask;
    }
}
05:12:30.558 info: Bookstore.Jobs.ExchangeRateImportJob[0] Exchange rate import, retry attempt 0
05:12:30.562 fail: Quartz.Core.JobRunShell[1052] Job DEFAULT.exchange-rates threw a JobExecutionException:  Parameters: refire = False, unscheduleFiringTrigger = False, unscheduleAllTriggers = False   Quartz.JobExecutionException: Central bank API returned 503 [...]
05:12:30.563 info: Quartz.Core.JobRunShell[1056] Job of trigger DEFAULT.exchange-rates failed; retry 1 of 3 scheduled for 11/08/2026 02:12:32 +00:00
05:12:32.563 info: Bookstore.Jobs.ExchangeRateImportJob[0] Exchange rate import, retry attempt 1
05:12:32.563 info: Quartz.Core.JobRunShell[1056] Job of trigger DEFAULT.exchange-rates failed; retry 2 of 3 scheduled for 11/08/2026 02:12:36 +00:00
05:12:36.567 info: Bookstore.Jobs.ExchangeRateImportJob[0] Exchange rate import, retry attempt 2
05:12:36.567 info: Bookstore.Jobs.ExchangeRateImportJob[0] Imported 34 exchange rates

Bekleme her denemede ikiye katlanıyor: 2 saniye, 4 saniye. Quartz'ın XML (Extensible Markup Language) dokümantasyonu birkaç ayrıntıyı netleştiriyor. MaxAttempts, ilk hatadan sonraki tekrar sayısı; [RetryPolicy(3, ...)] toplamda en fazla dört çalışma demek. Policy sırasıyla trigger'ın kendi policy'sinden, job tipinin attribute'undan ve scheduler'ın UseDefaultRetryPolicy ile verilen varsayılanından aranıyor. En önemlisi: bir retry, trigger'ın bir sonraki normal çalışmasının yerini almaz; tekrar, düzenli çalışmaya bir saniyeden yakın düşecekse atlanıyor. Yani beklemeleri trigger aralığından uzun olan bir policy sessizce hiçbir şey yapmaz.

Idempotent Tasarım: Önce Sahiplen

Baştaki soruya geldik. Fatura e-postası için gönderilen her e-postayı bir tabloda tutuyoruz ve IdempotencyKey kolonunda unique index var:

public enum EmailStatus { Sending, Sent }

public sealed class OutgoingEmail
{
    public int Id { get; set; }
    public required string IdempotencyKey { get; set; }
    public EmailStatus Status { get; set; }
    public DateTime ClaimedAtUtc { get; set; }
    public string? ProviderMessageId { get; set; }
}

// BookstoreDbContext.OnModelCreating içinde
b.Entity<OutgoingEmail>().HasIndex(e => e.IdempotencyKey).IsUnique();

Naif job önce kontrol ediyor, sonra gönderiyor, en son kaydediyor:

// naif: önce kontrol, sonra gönder, en son kaydet
public async Task SendNaiveAsync(int orderId)
{
    string key = $"invoice-email:{orderId}";
    if (await db.OutgoingEmails.AnyAsync(e => e.IdempotencyKey == key))
        return;

    string messageId = await provider.SendAsync($"customer{orderId}@example.com", $"Invoice #{orderId}");
    db.OutgoingEmails.Add(new OutgoingEmail
    {
        IdempotencyKey = key, Status = EmailStatus.Sent, ClaimedAtUtc = DateTime.UtcNow, ProviderMessageId = messageId
    });
    await db.SaveChangesAsync();
}

Aynı job'u 42 numaralı sipariş için iki ayrı scope'ta, aynı anda çalıştırdım:

naive, two concurrent runs for order 42:
  DbUpdateException: Cannot insert duplicate key row in object 'dbo.OutgoingEmails' with unique index 'IX_OutgoingEmails_IdempotencyKey'. The duplicate key value is (invoice-email:42).
  emails delivered: 2

İki e-posta gitti. İki çalışma da AnyAsync'te "kayıt yok" gördü, ikisi de 200 ms süren gönderimi yaptı, ikincisi kaydederken unique index'e çarptı. Unique index veritabanını korudu ama müşteriyi korumadı: kontrol ile kayıt arasındaki pencerede dış dünyaya etki eden iş çoktan yapılmıştı. Bu bir check-then-act yarış durumu.

Düzeltmesi sırayı değiştirmek: önce unique key ile işi sahiplen, sonra dış etkiyi yap, en son sonucu işaretle.

// idempotent: önce unique key ile "sahiplen", sonra gönder, sonra işaretle
public async Task SendAsync(int orderId)
{
    string key = $"invoice-email:{orderId}";
    var email = new OutgoingEmail { IdempotencyKey = key, Status = EmailStatus.Sending, ClaimedAtUtc = DateTime.UtcNow };
    db.OutgoingEmails.Add(email);
    try
    {
        await db.SaveChangesAsync();
    }
    catch (DbUpdateException ex) when (ex.InnerException is SqlException { Number: 2601 or 2627 })
    {
        logger.LogInformation("{Key} already claimed by another run, skipping", key);
        return;
    }

    email.ProviderMessageId = await provider.SendAsync($"customer{orderId}@example.com", $"Invoice #{orderId}");
    email.Status = EmailStatus.Sent;
    await db.SaveChangesAsync();
    logger.LogInformation("{Key} sent as {MessageId}", key, email.ProviderMessageId);
}
claim-first, two concurrent runs for order 43:
05:12:16.790 info: Bookstore.Jobs.InvoiceEmailJob[0] invoice-email:43 already claimed by another run, skipping
05:12:17.011 info: Bookstore.Jobs.InvoiceEmailJob[0] invoice-email:43 sent as msg-4570d2f1
  emails delivered: 1

Bu kez tek e-posta gitti. İkinci çalışma INSERT'te SQL Server'ın 2601 numaralı duplicate key hatasını aldı ve sessizce çıktı. (İki çıktıda da EF Core'un bu hatalar için yazdığı fail seviyesindeki log satırlarını kısalık için çıkardım; yakalanan bir hatayı Error seviyesinde loglaması production'da gürültü üretebilir.) Sahiplenmeyi ayrı bir tabloyla değil, işin zaten yazdığı kayıtla yapmak da mümkün: "fatura numarası" gibi doğal bir unique key'in varsa onu kullan. EF Core'da transaction ve concurrency mekaniği için Entity Framework Core serisinin "EF Core'da Transaction'lar: SaveChanges, BeginTransaction ve Savepoint'ler" ve "EF Core'da Concurrency: Token, rowversion ve DbUpdateConcurrencyException" yazılarına bak.

Sahiplenip Ölen Job ve Doğal Idempotency

Önce sahiplenmenin de bir bedeli var. Job kaydı Sending olarak yazıp e-postayı göndermeden ölürse, retry geldiğinde kayıt zaten var ve job "başkası sahiplenmiş" deyip çıkar: e-posta hiç gitmez. Naif yöntem iki e-posta riskini, bu yöntem sıfır e-posta riskini taşıyor. Hangisini seçeceğin işin doğasına bağlı: ikinci bir onay e-postası can sıkıcı, ikinci bir ödeme çekimi kabul edilemez.

Sıfır e-posta riskini kapatmak için kayda bir sahiplenme zamanı (ClaimedAtUtc) koyduk. Belirli bir süreden (örneğin 10 dakika) eski Sending kayıtlarını, koşullu bir UPDATE ile yeniden sahiplenen bir job ya da retry adımı ekleyebilirsin: WHERE Status = 'Sending' AND ClaimedAtUtc < @cutoff. Koşul, aynı anda iki retry'ın birden yeniden sahiplenmesini engeller. Ama tekrar gönderip göndermemeye karar vermeden önce asıl soruyu sor: dış servis isteği almış mıydı? Bunu bilmenin en sağlam yolu bir sonraki bölümde.

Bazı işler ise doğası gereği idempotent ve hiçbir tabloya ihtiyaç duymaz. UPDATE Orders SET Status = 'Shipped' WHERE Id = 42 kaç kez çalışırsa çalışsın sonuç aynı; UPDATE Inventory SET Quantity = Quantity - 1 ise her çalışmada stoğu bir azaltır. Job'ları mümkün olduğunca "şu değere getir" (set, upsert) şeklinde, "şu kadar değiştir" (increment) şeklinde değil yaz. Artırmak zorundaysan hareketi kendi id'siyle bir tabloya yaz ve toplamı oradan hesapla.

Dış API'ye Idempotency-Key

Dış servis, isteği bir anahtarla tanıyabiliyorsa idempotency uçtan uca kurulur. Ödeme ve e-posta sağlayıcılarının birçoğu bunu Idempotency-Key başlığıyla destekliyor; IETF (Internet Engineering Task Force) bünyesinde bu başlık için bir taslak var (draft-ietf-httpapi-idempotency-key-header, 22 Ekim 2026'da yayımlanan 08 sürümü henüz RFC (Request for Comments) değil). Anahtarı her denemede aynı tutarsan sağlayıcı ikinci isteği yeni bir iş olarak değil, ilkinin tekrarı olarak görür. Davranışı görmek için sağlayıcıyı taklit eden küçük bir endpoint yazdım:

app.MapPost("/provider/emails", async (HttpRequest request, EmailRequest body) =>
{
    string? key = request.Headers["Idempotency-Key"];
    if (key is not null && Seen.TryGetValue(key, out string? existing))
        return Results.Ok(new { messageId = existing, replayed = true });

    await Task.Delay(100);
    string messageId = $"msg-{Interlocked.Increment(ref _deliveries):D4}";
    if (key is not null)
        Seen[key] = messageId;
    return Results.Ok(new { messageId, replayed = false });
});

Job'un aynı anahtarla iki kez denemesi:

for (int attempt = 1; attempt <= 2; attempt++)
{
    using var request = new HttpRequestMessage(HttpMethod.Post, "/provider/emails")
    {
        Content = JsonContent.Create(new IdempotencyKeyDemo.EmailRequest("ada@example.com", "Invoice #42"))
    };
    request.Headers.Add("Idempotency-Key", "invoice-email:42");
    using HttpResponseMessage response = await http.SendAsync(request);
    Console.WriteLine($"attempt {attempt}: {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
}
attempt 1: 200 {"messageId":"msg-0001","replayed":false}
attempt 2: 200 {"messageId":"msg-0001","replayed":true}

İkinci istek aynı messageId'yi aldı ve e-posta ikinci kez gitmedi. Anahtarı rastgele değil, işin kimliğinden türet: invoice-email:42 her retry'da, her worker'da, hatta job yeniden kuyruğa yazılsa bile aynı. Hangfire'ın job id'sini anahtar yapmak cazip görünebilir ama aynı fatura için elle yeniden oluşturulan bir job farklı bir id alır. Sağlayıcıların anahtarları sınırlı bir süre sakladığını da unutma; o süreyi aşan retry'lar yine çift iş üretebilir.

Üretimde Dikkat

Retry sayısını ve süresini işin değerine göre seç. Varsayılan 10 deneme ve yarım günü aşan toplam süre, bir sipariş onay e-postası için fazla uzun: müşteri e-postayı ertesi gün alırsa almasa da olur. Sipariş onayında 3 deneme ve dakikalar, gece raporunda daha uzun aralıklar mantıklı.

Denemeleri log'a ve metriğe dök. "Kaçıncı denemede başarılı oldu" dağılımı bir dış servisin sağlığını çok erken gösterir; birinci deneme başarı oranı düşmeye başladıysa sorun büyümeden önce görünür. Onuncu yazıda job id ve deneme sayısını structured log'a ekleyeceğiz.

Idempotency kayıtlarını temizle. Sahiplenme tablosu sonsuza kadar büyümemeli; idempotency penceresi (örneğin 30 gün) geçmiş kayıtları periyodik bir job ile sil. O job'un kendisi de idempotent: aynı kayıtları iki kez silmek zararsız.

Sık Düşülen Tuzaklar

"Önce kontrol et, sonra yap". Check-then-act iki eşzamanlı çalışmada iki kez yapar. Unique key ile önce sahiplen.

Kalıcı hatayı tekrar denemek. Doğrulama hatası on denemede düzelmez; sadece fark edilmesi gecikir. Kalıcı hatalar için ayrı bir exception tipi ve ExceptOn kullan.

Retry'ı idempotency yerine koymak. Retry çift çalışmayı azaltmaz, artırır; her retry job'u baştan çalıştırır.

Idempotency anahtarını her denemede yeniden üretmek. Guid.NewGuid() ile üretilen bir anahtar retry'da değişir ve hiçbir şeyi korumaz.

Increment yazan job'lar. Quantity = Quantity - 1 gibi göreli değişiklikler her tekrarda tekrar uygulanır. Mutlak değer yaz ya da hareketi kendi id'siyle kaydet.

Küçük Bir Deneme

★☆☆ SupplierInvoiceJobs.ImportAsync'te DelaysInSeconds'ı kaldır ve job'u her denemede hata verecek şekilde değiştir. İlk üç denemenin bekleme sürelerini log'dan oku; formülle uyuşuyor mu?

★★☆ InvoiceEmailJob'a bayat sahiplenmeleri yeniden alan bir adım ekle: 10 dakikadan eski Sending kaydını koşullu ExecuteUpdateAsync ile sahiplen. İki eşzamanlı çalışmada yalnız birinin sahiplendiğini test et.

★★★ InvoiceEmailJob'u sahte sağlayıcıyla birleştir: Idempotency-Key olarak invoice-email:{orderId} gönder ve job'u e-posta gönderildikten hemen sonra, durumu Sent yapmadan öldür. Retry'da hem sahiplenme kaydı hem sağlayıcı ne yapıyor, müşteri kaç e-posta alıyor?

Bir sonraki yazıda çift çalışmanın en yaygın kaynağına bakıyoruz: API'yi üç replica'ya çıkardığında her pod'daki BackgroundService'in gece raporunu üç kez üretmesi. SQL Server, PostgreSQL ve Redis ile distributed lock, leader election ve lock'un sahibi öldüğünde gerçekte ne kadar süre beklendiği.