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

Quartz.NET 4 ile Scheduling: Job, Trigger, Persistent JobStore ve Clustering

Mert Özen 8 Kas 2026 11 dk 1 görüntülenme
Quartz.NET 4 ile Scheduling: Job, Trigger, Persistent JobStore ve Clustering

Quartz.NET 4'te job ve trigger ayrı nesnelerdir, IJob ValueTask döner ve CancellationToken alır; DI ve hosting çekirdek Quartz paketinde. Aynı PostgreSQL veritabanını paylaşan iki node bir cluster olur: çalışan node kill -9 ile ölünce diğeri yaklaşık 15 saniye sonra durumu fark eder ve RequestRecovery işaretli job'u recovering=true ile yeniden çalıştırır. Açılışta kod tanımı veritabanındaki JobDataMap'in üzerine yazar; IgnoreDuplicates bunu önler.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Quartz.NET 4.7.2, Npgsql 10.0.3 ve PostgreSQL 17 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore tedarikçinin fiyat listesini sayfa sayfa çekiyor: her 10 saniyede bir sonraki sayfa, her sayfa 6 saniye sürüyor. İş, aynı PostgreSQL veritabanını paylaşan iki node'lu bir Quartz cluster'ında çalışıyor. Job aynı anda iki kez çalışmasın diye işaretli ve hangi sayfada kaldığını veritabanında tutuyor. Node'lardan biri 3. sayfayı işlerken kill -9 ile ölüyor. Diğer node durumu ne zaman fark eder? 3. sayfa hiç işlenmeden mi kalır, bir kez mi işlenir, iki kez mi?

Job ve Trigger: İki Ayrı Nesne

Hangfire'da "iş" ile "ne zaman" aynı çağrıdadır: Schedule(() => ..., TimeSpan). Quartz.NET bunları ayırır. Job (IJobDetail) neyin çalışacağını, kimliğini (JobKey) ve job'a ait veriyi (JobDataMap) taşır; trigger (ITrigger) ne zaman çalışacağını. Bir job'a birden fazla trigger bağlanabilir: aynı fiyat senkronizasyonu hafta içi her 10 dakikada, hafta sonu saatte bir.

Quartz 4'te job sınıfının imzası değişti:

// Quartz 4.7.2'deki IJob, reflection çıktısı
TYPE Quartz.IJob
  System.Threading.Tasks.ValueTask Execute(Quartz.IJobExecutionContext, System.Threading.CancellationToken)

Dönüş tipi ValueTask ve CancellationToken artık bir parametre. 3.x'te token context.CancellationToken içindeydi; parametre olunca token'ı alt çağrılara vermeyi unutmanı CA2016 kod analizi kuralı yakalayabiliyor. Bu ve diğer kırıcı değişiklikleri serinin son yazısında, 3.x'ten 4.x'e geçişte gerçek derleyici çıktısıyla göreceğiz.

4.0'ın en görünür değişikliği paketlerde. Eylül 2026'daki duyuruya göre DI (Dependency Injection), hosting, health check ve System.Text.Json serileştirme çekirdek Quartz paketine taşındı; quartz.config dosyası artık okunmuyor ve paket yalnız net10.0 hedefliyor. .NET Framework ya da netstandard gereken projeler için 3.x bakımda (şu an 3.22.9).

Kurulum: AddQuartz ve PostgreSQL JobStore

Quartz varsayılan olarak job'ları bellekte tutar (RAMJobStore); restart'ta hepsi gider. Kalıcılık ve clustering için ActiveX Data Objects .NET (ADO.NET) JobStore gerekiyor:

var builder = Host.CreateApplicationBuilder(args);
string node = builder.Configuration["Node"] ?? "node-a";

builder.Services.AddQuartz(q =>
{
    q.ConfigureScheduler(options =>
    {
        options.InstanceName = "bookstore";   // cluster'daki her node'da aynı
        options.InstanceId = node;            // her node'da farklı
    });

    q.UsePersistentStore(store =>
    {
        store.UsePostgres(builder.Configuration.GetConnectionString("Quartz")!);
        store.UseClustering();
        if (builder.Environment.IsDevelopment())
            store.ProvisionSchema();
    });

    q.AddJob<SupplierPriceSyncJob>(job => job
        .WithIdentity("supplier-price-sync")
        .UsingJobData("lastSyncedPage", 0)
        .RequestRecovery());
    q.AddTrigger(t => t
        .ForJob("supplier-price-sync")
        .WithIdentity("every-10-seconds")
        .WithCronSchedule("0/10 * * ? * *"));
});

// her node açılışında kod tanımı veritabanındaki job'un (ve JobDataMap'inin) üzerine yazmasın
builder.Services.Configure<QuartzOptions>(o => o.Scheduling.IgnoreDuplicates = true);

builder.Services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);

var host = builder.Build();
host.Run();

Bir iki ayrıntı. Quartz veritabanı sürücüsünü adıyla yüklüyor; UsePostgres için Npgsql'i projeye kendin eklemelisin, yoksa hata ancak scheduler başlarken çıkıyor. Şemayı Quartz'ın deposundaki database/tables/tables_postgres.sql script'i kurar; 4.7.2'nin script'i 15 tablo oluşturuyor (qrtz_job_details, qrtz_triggers, qrtz_cron_triggers, qrtz_fired_triggers, qrtz_scheduler_state, qrtz_locks ve yeni qrtz_execution_history gibi). Development'ta ProvisionSchema() eksik tabloları kendisi yaratıyor; dokümantasyona göre varsayılan ayar Validate: tablolar eksikse scheduler başlamayı reddediyor. Production'da şemayı kendi migration sürecinle kur.

AddQuartzHostedService'teki WaitForJobsToComplete = true, host kapanırken çalışan job'ların bitmesini bekletiyor. Aşağıdaki çıktıda bir sayfanın SIGTERM (terminate signal) geldikten 4 saniye sonra "done" yazdığını göreceksin; bu bekleme de serinin ikinci yazısındaki ShutdownTimeout bütçesinin içinde.

JobDataMap ve [PersistJobDataAfterExecution]

[DisallowConcurrentExecution]      // aynı JobKey cluster genelinde aynı anda bir kez çalışır
[PersistJobDataAfterExecution]     // JobDataMap'teki değişiklik veritabanına geri yazılır
public sealed class SupplierPriceSyncJob(ILogger<SupplierPriceSyncJob> logger) : IJob
{
    public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken)
    {
        int page = context.JobDetail.JobDataMap.GetInt("lastSyncedPage") + 1;
        logger.LogInformation("[{Node}] syncing page {Page} (recovering={Recovering})",
            context.Scheduler.SchedulerInstanceId, page, context.Recovering);

        await Task.Delay(TimeSpan.FromSeconds(6), cancellationToken); // tedarikçi API'si yavaş

        context.JobDetail.JobDataMap["lastSyncedPage"] = page;
        logger.LogInformation("[{Node}] page {Page} done", context.Scheduler.SchedulerInstanceId, page);
    }
}

[DisallowConcurrentExecution] aynı JobKey'in aynı anda iki kez çalışmasını engelliyor; dokümantasyona göre cluster'da da geçerli: job bir anda tek bir node'da çalışır, diğer trigger'ları bekler. [PersistJobDataAfterExecution] ise job'un JobDataMap'te yaptığı değişikliği çalışma bitince veritabanına geri yazıyor; bir sonraki çalışma kaldığı sayfayı oradan okuyor.

Burada gerçek bir tuzağa düştüm. Cluster'ı ilk kez çalıştırıp sayfa 5'e kadar ilerlettim, node'ları durdurup yeniden başlattım:

# ilk koşu, IgnoreDuplicates yok
05:07:00.009 info: Bookstore.Scheduler.SupplierPriceSyncJob[0] [node-a] syncing page 5 (recovering=False)
05:07:02.013 info: Microsoft.Hosting.Lifetime[0] Application is shutting down...
05:07:06.010 info: Bookstore.Scheduler.SupplierPriceSyncJob[0] [node-a] page 5 done
# node'lar yeniden başlatıldı
05:07:18.223 info: Microsoft.Hosting.Lifetime[0] Application started. Press Ctrl+C to shut down.
05:07:20.023 info: Bookstore.Scheduler.SupplierPriceSyncJob[0] [node-b] syncing page 1 (recovering=False)

Sayfa 6 değil, sayfa 1. Sebep AddJob'un açılışta her seferinde çalışması ve UsingJobData("lastSyncedPage", 0)'ı veritabanındaki kaydın üzerine yazması. Quartz'ın XML (Extensible Markup Language) dokümantasyonunda SchedulingOptions.OverwriteExistingData için "varsayılan olarak açık" yazıyor: kodda tanımlanan job ya da trigger, aynı anahtarla saklananın yerine geçer. Job'un kodda tanımlanan özelliklerini her deploy'da güncellemek için bu mantıklı, ama çalışma sırasında biriken state için yıkıcı. Çözüm, Program.cs'teki IgnoreDuplicates ayarı: kayıt zaten varsa dokunmuyor. Daha sağlam olanı ise ilerleme gibi iş state'ini JobDataMap'e değil, kendi tablona yazmak; dokuzuncu yazıdaki checkpoint tablosu tam olarak bu.

Clustering: Aynı Veritabanını Paylaşan İki Node

Cluster kurmak için ayrı bir sunucu yok: aynı veritabanını, aynı InstanceName'i ve aynı tablo önekini kullanan, ama farklı InstanceId'ye sahip node'lar bir cluster'dır. Hangi node'un hangi trigger'ı çalıştıracağına veritabanındaki satır lock'larıyla karar veriliyor. İki node başlattım (--Node node-a ve --Node node-b):

05:06:19.558 info: Microsoft.Hosting.Lifetime[0] Application started. Press Ctrl+C to shut down.
05:06:20.024 info: Bookstore.Scheduler.SupplierPriceSyncJob[0] [node-a] syncing page 1 (recovering=False)
05:06:22.330 info: Microsoft.Hosting.Lifetime[0] Application started. Press Ctrl+C to shut down.
05:06:26.027 info: Bookstore.Scheduler.SupplierPriceSyncJob[0] [node-a] page 1 done
05:06:30.007 info: Bookstore.Scheduler.SupplierPriceSyncJob[0] [node-a] syncing page 2 (recovering=False)
05:06:36.009 info: Bookstore.Scheduler.SupplierPriceSyncJob[0] [node-a] page 2 done
05:06:40.006 info: Bookstore.Scheduler.SupplierPriceSyncJob[0] [node-a] syncing page 3 (recovering=False)
05:06:46.008 info: Bookstore.Scheduler.SupplierPriceSyncJob[0] [node-a] page 3 done
05:06:50.005 info: Bookstore.Scheduler.SupplierPriceSyncJob[0] [node-a] syncing page 4 (recovering=False)
05:06:56.007 info: Bookstore.Scheduler.SupplierPriceSyncJob[0] [node-a] page 4 done

Bütün sayfaları node-a işledi. Bu bir hata değil: Quartz'ın clustering dokümantasyonuna göre trigger'ı hangi node önce alırsa o çalıştırır; round-robin bir dağıtım yok. Yük dağılımı, çok sayıda job ve trigger olduğunda istatistiksel olarak oluşuyor. Tek bir job için clustering'in asıl faydası yük paylaşımı değil, bir node ölünce işin devam etmesi.

Dokümantasyonun iki şartını atlama: node'ların saatleri birbirine bir saniye içinde senkron olmalı (NTP (Network Time Protocol) gibi bir zaman servisiyle) ve InstanceId her node'da farklı olmalı. Kubernetes'te pod adı doğal bir seçim; GenerateInstanceId = true de bir id üretiyor.

Bir Node Ölünce: Recovery ve Misfire

Baştaki soru. IgnoreDuplicates ile temiz bir veritabanında iki node başlattım ve 3. sayfayı işleyen node-a'yı kill -9 ile öldürdüm:

05:09:00.056 info: Bookstore.Scheduler.SupplierPriceSyncJob[0] [node-a] syncing page 1 (recovering=False)
05:09:06.057 info: Bookstore.Scheduler.SupplierPriceSyncJob[0] [node-a] page 1 done
05:09:10.009 info: Bookstore.Scheduler.SupplierPriceSyncJob[0] [node-a] syncing page 2 (recovering=False)
05:09:16.011 info: Bookstore.Scheduler.SupplierPriceSyncJob[0] [node-a] page 2 done
05:09:20.010 info: Bookstore.Scheduler.SupplierPriceSyncJob[0] [node-a] syncing page 3 (recovering=False)
05:09:22.307 kill -9 node-a
05:09:37.897 warn: Quartz.Impl.AdoJobStore.LocalTransactionJobStore[3503] ClusterManager: detected 1 failed or restarted instances.
05:09:37.903 warn: Quartz.Impl.AdoJobStore.LocalTransactionJobStore[3511] ClusterManager: ......Deferred recovery of 1 executing [DisallowConcurrentExecution] job(s).
05:09:45.399 warn: Quartz.Impl.AdoJobStore.LocalTransactionJobStore[3503] ClusterManager: detected 1 failed or restarted instances.
05:09:45.430 warn: Quartz.Impl.AdoJobStore.LocalTransactionJobStore[3509] ClusterManager: ......Scheduled 1 recoverable job(s) for recovery.
05:09:45.468 info: Bookstore.Scheduler.SupplierPriceSyncJob[0] [node-b] syncing page 3 (recovering=True)
05:09:51.470 info: Bookstore.Scheduler.SupplierPriceSyncJob[0] [node-b] page 3 done
05:09:51.498 info: Bookstore.Scheduler.SupplierPriceSyncJob[0] [node-b] syncing page 4 (recovering=False)

Zaman çizelgesi şöyle. Node-a 09:20'de 3. sayfaya başladı ve 09:22.3'te öldü. Node-b'nin ClusterManager'ı node-a'nın check-in'lerinin kesildiğini 09:37.9'da, yani yaklaşık 15 saniye sonra fark etti: Quartz'ın varsayılanları CheckinInterval 7,5 saniye ve CheckinMisfireThreshold 7,5 saniye. İlk denemede recovery'yi erteledi ("Deferred recovery of 1 executing [DisallowConcurrentExecution] job(s)"), bir sonraki check-in turunda job'u recovery için planladı ve 09:45.5'te 3. sayfa node-b'de recovering=True ile yeniden çalıştı.

Yani 3. sayfa iki kez işlendi: bir kez yarıda kalan node-a'da, bir kez de baştan node-b'de. Bu Quartz'ın bir eksikliği değil, dağıtık sistemlerin doğası: bir node'un iş ortasında öldüğünü, işin ne kadarını yaptığını bilmeden ancak sessizliğinden anlayabilirsin. RequestRecovery() "en az bir kez" demek; job'un yarıda kalmış bir çalışmayı tekrar yapmaya dayanıklı, yani idempotent olması gerekiyor. Dokümantasyona göre RequestRecovery olmayan bir job için o çalışma tamamen kaybolur; bu da "en fazla bir kez". İkisi arasındaki seçimi ve idempotency'yi bir sonraki yazıda ayrıntılı işleyeceğiz.

Bir de kaybolan slotlar var. 09:30 ve 09:40 trigger'ları, [DisallowConcurrentExecution] job'u hâlâ "çalışıyor" göründüğü için ateşlenmedi. Recovery bittiğinde (09:51.5) Quartz kaçırılan çalışma için hemen bir tur daha attı: 4. sayfa 09:51.498'de, tam saniye sınırını beklemeden başladı. Bu, cron trigger'ının varsayılan misfire davranışı. Quartz'ın XML dokümantasyonuna göre cron trigger'da SmartPolicy varsayılan ve FireAndProceed anlamına geliyor: kaçırılan çalışmalar için bir kez hemen ateşle, sonra takvime dön. Diğer seçenekler DoNothing (kaçırılanları atla, bir sonraki slotu bekle) ve IgnoreMisfires (kaçırılan her çalışmayı sırayla hemen yap):

// kaçırılan çalışmaları atla, bir sonraki slotu bekle
ITrigger nightly = TriggerBuilder.Create()
    .WithIdentity("nightly-sales-report")
    .WithCronSchedule("0 0 3 * * ?", c => c
        .InTimeZone(istanbul)
        .WithMisfireInstruction(CronTriggerMisfireInstruction.DoNothing))
    .Build();
Console.WriteLine($"{nightly.Key.Name}: misfire instruction code {nightly.MisfireInstructionCode} ({(CronTriggerMisfireInstruction)nightly.MisfireInstructionCode})");

// nightly-sales-report: misfire instruction code 2 (DoNothing)

Bir trigger'ın ne zaman "kaçırılmış" sayılacağını MisfireThreshold belirliyor. Varsayılanları yazdırdım:

var ado = new AdoJobStoreOptions();
var mem = new InMemoryJobStoreOptions();
var cl = new ClusteringOptions();
Console.WriteLine($"Ado.MisfireThreshold={ado.MisfireThreshold} InMemory.MisfireThreshold={mem.MisfireThreshold} Checkin={cl.CheckinInterval} CheckinMisfire={cl.CheckinMisfireThreshold}");

// Ado.MisfireThreshold=00:01:00 InMemory.MisfireThreshold=00:00:05 Checkin=00:00:07.5000000 CheckinMisfire=00:00:07.5000000

ADO.NET store'da 60 saniye, bellek içi store'da 5 saniye. Migration rehberine göre 4.0'da yalnız ayar yapılmamış bellek içi store'un eşiği 60 saniyeden 5 saniyeye indi. Gece raporu için DoNothing genellikle doğru seçim (sabah 09:00'da gece raporunu üretmek kimsenin işine yaramıyorsa); stok senkronizasyonu için FireAndProceed (bir an önce güncel veriye dönmek istiyorsun).

RFC 5545 Recurrence Trigger'ları

Cron güçlü ama her kuralı ifade edemiyor. "İki haftada bir salı ve perşembe" cron'la yazılamaz, çünkü cron'un "iki haftada bir" kavramı yok. Quartz 4.0, takvim uygulamalarının kullandığı iCalendar standardının, RFC (Request for Comments) 5545'in tekrarlama kurallarını kabul eden RecurrenceTrigger'ı getirdi:

TimeZoneInfo istanbul = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul");
var from = new DateTimeOffset(2026, 11, 8, 0, 0, 0, TimeSpan.FromHours(3));

// ayın son iş günü 18:00'de: cron ile "LW", RFC 5545 ile BYSETPOS=-1
ITrigger cron = TriggerBuilder.Create()
    .WithIdentity("month-end-cron")
    .StartAt(from)
    .WithCronSchedule("0 0 18 LW * ?", c => c.InTimeZone(istanbul))
    .Build();

ITrigger rrule = TriggerBuilder.Create()
    .WithIdentity("month-end-rrule")
    .StartAt(from)
    .WithRecurrenceSchedule("FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1;BYHOUR=18;BYMINUTE=0;BYSECOND=0",
        r => r.InTimeZone(istanbul))
    .Build();

// her iki haftada bir salı ve perşembe 10:00: cron'un ifade edemediği kural
ITrigger biweekly = TriggerBuilder.Create()
    .WithIdentity("biweekly-supplier-call")
    .StartAt(from)
    .WithRecurrenceSchedule("FREQ=WEEKLY;INTERVAL=2;BYDAY=TU,TH;BYHOUR=10;BYMINUTE=0;BYSECOND=0",
        r => r.InTimeZone(istanbul))
    .Build();

foreach (ITrigger t in new[] { cron, rrule, biweekly })
{
    var times = new List<string>();
    DateTimeOffset? next = t.GetFireTimeAfter(from.AddSeconds(-1));
    while (next is not null && times.Count < 6)
    {
        times.Add(TimeZoneInfo.ConvertTime(next.Value, istanbul).ToString("ddd yyyy-MM-dd HH:mm"));
        next = t.GetFireTimeAfter(next);
    }
    Console.WriteLine($"{t.Key.Name}:");
    Console.WriteLine("  " + string.Join(Environment.NewLine + "  ", times));
}
month-end-cron:
  Mon 2026-11-30 18:00
  Thu 2026-12-31 18:00
  Fri 2027-01-29 18:00
  Fri 2027-02-26 18:00
  Wed 2027-03-31 18:00
  Fri 2027-04-30 18:00
month-end-rrule:
  Mon 2026-11-30 18:00
  Thu 2026-12-31 18:00
  Fri 2027-01-29 18:00
  Fri 2027-02-26 18:00
  Wed 2027-03-31 18:00
  Fri 2027-04-30 18:00
biweekly-supplier-call:
  Tue 2026-11-17 10:00
  Thu 2026-11-19 10:00
  Tue 2026-12-01 10:00
  Thu 2026-12-03 10:00
  Tue 2026-12-15 10:00
  Thu 2026-12-17 10:00

"Ayın son iş günü" iki yolla da yazılabiliyor ve sonuçlar aynı. İki haftalık kural ise ancak RFC 5545 ile mümkün. Haftanın ilk günü (WKST) varsayılan olarak pazartesi olduğundan, 8 Kasım pazar günü başlayan kuralın ilk haftası 2–8 Kasım; oradaki salı ve perşembe başlangıçtan önce kaldığı için ilk çalışma iki hafta sonrasının salısı, 17 Kasım.

Hangfire ile Karşılaştırma

KonuHangfire 1.8Quartz.NET 4
Temel modelMethod çağrısını kuyruğa yazJob ve trigger ayrı nesneler
Anlık iş (enqueue)Enqueue ile tek satırHemen tetiklenen bir trigger ile
ZamanlamaCron (Cronos), delayed jobCron, simple, calendar interval, RFC 5545, takvimler
Kaçırılan çalışmaRecurring job'da MisfireHandlingMode (Relaxed, Strict, Ignorable)Trigger başına misfire instruction
RetryVarsayılan 10 denemeVarsayılan yok; retry policy ile
Çok nodeStorage'ı paylaşan server'larClustering ve recovery
Hedefnetstandard2.0 ve .NET FrameworkYalnız net10.0 (3.x bakımda)
LisansCore LGPL (GNU Lesser General Public License), Pro/Ace ücretliApache 2.0

Kaba kural önceki yazıdakiyle aynı: işlerin çoğu "bunu arka planda yap ve tekrar dene" ise Hangfire, işlerin çoğu karmaşık takvim ve cluster'da tek çalışma ise Quartz. Aynı uygulamada ikisini birden kullanan ekipler de var; ama iki ayrı job altyapısının iki ayrı operasyon yükü olduğunu unutma.

Üretimde Dikkat

Recovery süresini bil. Varsayılan ayarlarla bir node'un ölümü yaklaşık 15 saniyede fark ediliyor ve bu sürede o node'daki [DisallowConcurrentExecution] job'ları hiçbir yerde çalışmıyor. Dakikada bir çalışan bir iş için önemsiz; saniyede bir çalışan bir iş için değil.

Veritabanını izle. Cluster'daki her node, trigger almak için veritabanında satır lock'ları kullanıyor; node sayısı ve trigger yoğunluğu arttıkça qrtz_locks tablosu bir darboğaz olabilir. Quartz 4'ün OpenTelemetry metric'leri arasında job store işlemlerinin süresi ve lock bekleme süresi var; onuncu yazıda bunlara bakacağız.

Schema migration'ını deploy'dan önce yap. 4.x'in ara sürümleri de şemaya kolon ekliyor (4.6 üç, 4.7 bir zorunlu migration getirdi) ve migration rehberine göre yeni sürüm node eksik kolonla başlamayı reddediyor.

Sık Düşülen Tuzaklar

İlerlemeyi JobDataMap'te tutup OverwriteExistingData'yı unutmak. Her açılışta kod tanımı, biriken state'in üzerine yazılır.

RequestRecovery'yi idempotent olmayan bir job'a koymak. Recovery, yarıda kalan çalışmayı baştan tekrar eder; job iki kez çalışmaya dayanıklı değilse çift kayıt ya da çift e-posta üretir.

Node'lara aynı InstanceId'yi vermek. Bir node, diğerinin check-in kaydını kendi kaydı sanar; ölü node fark edilmez.

Npgsql ya da Microsoft.Data.SqlClient'ı eklemeyi unutmak. Quartz sürücüyü adıyla yükler; eksik sürücü derlemede değil, scheduler başlarken hata verir.

Misfire davranışını varsayılana bırakmak. Gece raporu için FireAndProceed, uygulama sabah ayağa kalktığında raporu mesai saatinde üretir.

Küçük Bir Deneme

★☆☆ Trigger'ı DoNothing misfire instruction'ıyla tanımla ve failover denemesini tekrarla. Recovery'den sonra 4. sayfa ne zaman başlıyor?

★★☆ Üçüncü bir node ekle ve 10 farklı job tanımla (her biri farklı JobKey). Bir dakika boyunca hangi node'un kaç job çalıştırdığını say. Dağılım ne kadar dengeli?

★★★ SupplierPriceSyncJob'un ilerlemesini JobDataMap yerine kendi tablona yaz ve sayfa güncellemesini işin kendisiyle aynı transaction'a al. Failover denemesinde 3. sayfa hâlâ iki kez işleniyor mu? İşleniyorsa sonuç iki kez mi uygulanıyor?

Bir sonraki yazıda bu yazıda iki kez gördüğümüz gerçeğin üzerine gidiyoruz: background job'lar en az bir kez, bazen iki kez çalışır. Hangfire'ın AutomaticRetry'ı, Quartz'ın retry policy'leri, transient ve kalıcı hatayı ayırmak ve fatura e-postasının iki kez gitmesini idempotency key ile önlemek.