Hangfire ile Kalıcı Job'lar: Fire-and-Forget, Delayed, Recurring ve Continuation
Hangfire bir method çağrısını tip adı, method adı, parametre tipleri ve JSON'a serialize edilmiş argümanlarla veritabanına yazar; restart'ta kaybolmaz ve varsayılan olarak 10 kez yeniden denenir. Bu yüzden argüman olarak yalnız id geçilir: DbContext serialize edilemez, entity ise bayat bir kopya olarak saklanır. Method'u yeniden adlandırınca kuyrukta bekleyen eski job'lar bulunamayan method hatasıyla retry'a düşer.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Hangfire 1.8.25 (Hangfire.AspNetCore ve Hangfire.SqlServer), Microsoft.Data.SqlClient 7.1.1 ve SQL (Structured Query Language) Server 2025 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'da sipariş onaylandığında üç iş var: onay e-postası hemen, fatura e-postası onay e-postası başarılı olursa, yorum isteği ise üç gün sonra. Hepsi Hangfire ile kuyruğa yazıldı ve uygulama kapalıyken veritabanında bekliyor. Bu sırada bir geliştirici SendInvoiceAsync method'unun adını SendInvoiceEmailAsync yapıp yeni sürümü deploy ediyor. Yeni sürüm açıldığında bekleyen fatura job'una ne olur? Yeni ada otomatik mi bağlanır, sessizce mi atlanır, yoksa başka bir şey mi olur?
Kurulum: SQL Server Storage ve Hangfire Server
Hangfire iki parçadan oluşur: job'ları storage'a yazan bir client ve storage'dan okuyup çalıştıran bir server. İkisi aynı process'te de olabilir, ayrı process'lerde de. ASP.NET Core'da kurulum:
builder.Services.AddHangfire(config => config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseSqlServerStorage(builder.Configuration.GetConnectionString("Hangfire")));
builder.Services.AddHangfireServer(options =>
{
options.WorkerCount = 5;
options.Queues = ["critical", "default"]; // sıra önceliktir
});
builder.Services.AddScoped<IEmailSender, LogEmailSender>();
builder.Services.AddScoped<OrderEmailJobs>();
builder.Services.AddScoped<InventoryReportJob>();
UseSqlServerStorage, SQL Server'da HangFire şemasını ilk açılışta kendisi oluşturur. Hangfire.SqlServer bir SQL client paketi getirmez; Microsoft.Data.SqlClient'ı projeye kendin eklersin, ikisi de varsa Hangfire dokümantasyonuna göre Microsoft.Data.SqlClient tercih edilir.
Derlemede gözden kaçırmaman gereken bir ayrıntı: Hangfire.Core netstandard2.0 için Newtonsoft.Json'a 11.0.1 alt sınırıyla bağımlı ve NuGet bu sürümü seçiyor. İlk build'de şu uyarılar geldi:
warning NU1903: Package 'Newtonsoft.Json' 11.0.1 has a known high severity vulnerability, https://github.com/advisories/GHSA-5crp-9r3c-p9vr
warning NU1903: Package 'System.Security.Cryptography.Pkcs' 9.0.18 has a known high severity vulnerability, https://github.com/advisories/GHSA-846f-f764-76mx
İkinci uyarı Hangfire'dan değil, Microsoft.Data.SqlClient 7.1.1'in getirdiği System.Security.Cryptography.Pkcs 9.0.18'den. İkisini de projede açıkça daha yeni bir sürüme yükseltmek uyarıları kapattı:
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.25" />
<PackageReference Include="Hangfire.SqlServer" Version="1.8.25" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.1.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.14" />
İlk açılışın log'u, varsayılanları gösteriyor:
05:03:23.466 info: Hangfire.SqlServer.SqlServerObjectsInstaller[0] Start installing Hangfire SQL objects...
05:03:23.972 info: Hangfire.SqlServer.SqlServerObjectsInstaller[0] Hangfire SQL objects installed.
05:03:24.073 info: Hangfire.BackgroundJobServer[0] Starting Hangfire Server using job storage: 'SQL Server: localhost,17103@Bookstore05'
05:03:24.073 info: Hangfire.BackgroundJobServer[0] Using the following options for SQL Server job storage: Queue poll interval: 00:00:00.
05:03:24.073 info: Hangfire.BackgroundJobServer[0] Using the following options for Hangfire Server: Worker count: 5 Listening queues: 'critical', 'default' Shutdown timeout: 00:00:15 Schedule polling interval: 00:00:15
05:03:24.092 info: Hangfire.Server.BackgroundServerProcess[0] Server mac:7753:ab84a3e2 is starting the registered dispatchers: ServerWatchdog, ServerJobCancellationWatcher, SqlServerHeartbeatProcess, ExpirationManager, CountersAggregator, Worker, DelayedJobScheduler, RecurringJobScheduler...
Kendi ayarlarıma dokunmadan önce varsayılanları ayrıca yazdırdım: WorkerCount=20 Queues=[default]. Hangfire'ın kaynak kodunda varsayılan worker sayısı Math.Min(Environment.ProcessorCount * 5, 20); 10 çekirdekli makinemde bu 20. Her worker bir job'u çalıştıran bir thread; job'ların çoğu I/O (input/output) bekliyorsa bu sayı yeterli, CPU (Central Processing Unit) yoğunsa düşürmek gerekir. Queue listesi sıralıdır: worker'lar önce critical'a bakar, orada iş yoksa default'a geçer. Bu bir öncelik mekanizması ama adil değil; critical hiç boşalmazsa default hiç işlenmez.
Dört Job Tipi: Enqueue, Schedule, ContinueJobWith ve Recurring
app.MapPost("/orders/{id:int}/confirm", (int id, IBackgroundJobClient jobs) =>
{
// fire-and-forget: hemen kuyruğa
string emailJobId = jobs.Enqueue<OrderEmailJobs>(j => j.SendConfirmationAsync(id, CancellationToken.None));
// continuation: e-posta başarılı olursa fatura
string invoiceJobId = jobs.ContinueJobWith<OrderEmailJobs>(emailJobId,
j => j.SendInvoiceAsync(id, CancellationToken.None));
// delayed: üç gün sonra yorum isteği (demoda 5 saniye)
string reviewJobId = jobs.Schedule<OrderEmailJobs>(j => j.SendReviewRequestAsync(id, CancellationToken.None),
TimeSpan.FromSeconds(5));
return Results.Accepted(value: new { emailJobId, invoiceJobId, reviewJobId });
});
public sealed class OrderEmailJobs(IEmailSender email)
{
public Task SendConfirmationAsync(int orderId, CancellationToken ct) =>
email.SendAsync($"customer{orderId}@example.com", $"Order #{orderId} confirmed", ct);
public Task SendInvoiceAsync(int orderId, CancellationToken ct) =>
email.SendAsync($"customer{orderId}@example.com", $"Invoice for order #{orderId}", ct);
public Task SendReviewRequestAsync(int orderId, CancellationToken ct) =>
email.SendAsync($"customer{orderId}@example.com", $"How was order #{orderId}?", ct);
}
IRecurringJobManager recurring = app.Services.GetRequiredService<IRecurringJobManager>();
recurring.AddOrUpdate<InventoryReportJob>(
"nightly-inventory-report",
job => job.RunAsync(CancellationToken.None),
"0 3 * * *",
new RecurringJobOptions { TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul") });
IBackgroundJobClient DI'dan (Dependency Injection) gelen bir service; static BackgroundJob sınıfı yerine bunu kullanmak test edilebilirliği artırır. Expression'daki CancellationToken.None sadece bir yer tutucu: Hangfire job'u çalıştırırken buraya server'ın kapanışında iptal edilen gerçek bir token koyuyor. OrderEmailJobs'ı da Hangfire, ASP.NET Core'un DI container'ından her job için yeni bir scope açarak oluşturuyor; job sınıfının constructor'ında scoped service almak bu yüzden güvenli.
Endpoint'e bir istek attım:
$ curl -s -X POST http://127.0.0.1:17105/orders/42/confirm
{"emailJobId":"1","invoiceJobId":"2","reviewJobId":"3"}
05:03:31.352 info: Bookstore.Api.LogEmailSender[0] Email to customer42@example.com: Order #42 confirmed
05:03:31.381 info: Bookstore.Api.LogEmailSender[0] Email to customer42@example.com: Invoice for order #42
05:03:39.151 info: Bookstore.Api.LogEmailSender[0] Email to customer42@example.com: How was order #42?
Onay e-postası ve continuation olan fatura e-postası aynı saniye içinde gitti. Yorum isteğini 5 saniye sonraya planlamıştım ama 31.352'de değil 39.151'de çalıştı. Sebep, scheduled job'ları kuyruğa taşıyan DelayedJobScheduler'ın varsayılan olarak 15 saniyede bir yoklaması (SchedulePollingInterval). "Üç gün sonra" için bu fark önemsiz; saniye hassasiyeti gerekiyorsa Hangfire doğru araç değil.
Job Nasıl Saklanır?
Hangfire'ı anlamanın en kısa yolu tablolarına bakmak. HangFire şemasında 11 tablo var: AggregatedCounter, Counter, Hash, Job, JobParameter, JobQueue, List, Schema, Server, Set, State. Az önceki üç job:
SELECT Id, StateName, InvocationData, Arguments FROM HangFire.Job ORDER BY Id;
1|Succeeded|{"t":"Bookstore.Api.OrderEmailJobs, p05","m":"SendConfirmationAsync","p":["System.Int32","System.Threading.CancellationToken, mscorlib"]}|["42",null]
2|Succeeded|{"t":"Bookstore.Api.OrderEmailJobs, p05","m":"SendInvoiceAsync","p":["System.Int32","System.Threading.CancellationToken, mscorlib"]}|["42",null]
3|Succeeded|{"t":"Bookstore.Api.OrderEmailJobs, p05","m":"SendReviewRequestAsync","p":["System.Int32","System.Threading.CancellationToken, mscorlib"]}|["42",null]
Bir job, yani üç şey: hangi tipin (t) hangi method'u (m), hangi parametre tipleriyle (p) ve hangi argümanlarla. Argümanlar JSON (JavaScript Object Notation) olarak ayrı bir kolonda: "42" sipariş id'si, null ise CancellationToken'ın yeri. UseSimpleAssemblyNameTypeSerializer sayesinde tip adı assembly sürümü olmadan yazılıyor (p05); bu, uygulamanın her yeni sürümünde eski job'ların tip adının çözülebilmesi için önemli.
Job'un hayatı State tablosunda. Fatura job'u (2) ve yorum isteği (3):
SELECT JobId, Name, Reason, CreatedAt FROM HangFire.State WHERE JobId IN (2,3) ORDER BY Id;
2|Awaiting|NULL|2026-11-08 02:03:31.343
3|Scheduled|NULL|2026-11-08 02:03:31.353
2|Enqueued|NULL|2026-11-08 02:03:31.363
2|Processing|NULL|2026-11-08 02:03:31.377
2|Succeeded|NULL|2026-11-08 02:03:31.383
3|Enqueued|Triggered by DelayedJobScheduler|2026-11-08 02:03:39.133
3|Processing|NULL|2026-11-08 02:03:39.150
3|Succeeded|NULL|2026-11-08 02:03:39.153
Continuation, ebeveyni bitene kadar Awaiting'de bekliyor; delayed job Scheduled'da bekleyip "Triggered by DelayedJobScheduler" ile Enqueued'a geçiyor. Recurring job ise bir job değil, bir tanım; Hash tablosunda duruyor ve her tetiklemede yeni bir job üretiyor:
SELECT [Key], Field, Value FROM HangFire.Hash WHERE [Key] LIKE 'recurring-job:%' ORDER BY Field;
recurring-job:nightly-inventory-report|CreatedAt|1794103403995
recurring-job:nightly-inventory-report|Cron|0 3 * * *
recurring-job:nightly-inventory-report|Job|{"t":"Bookstore.Api.InventoryReportJob, p05","m":"RunAsync","p":["System.Threading.CancellationToken, mscorlib"],"a":[null]}
recurring-job:nightly-inventory-report|NextExecution|1794182400000
recurring-job:nightly-inventory-report|Queue|default
recurring-job:nightly-inventory-report|TimeZoneId|Europe/Istanbul
recurring-job:nightly-inventory-report|V|2
NextExecution milisaniye cinsinden Unix zamanı: 1794182400000, 9 Kasım 2026 00:00 UTC (Coordinated Universal Time), yani İstanbul'da 03:00. Bir önceki yazıda konuştuğumuz time zone burada kalıcı olarak saklanıyor.
Argümanlar: Sadece Id Geç
Argümanlar serialize edildiğine göre, entity'yi doğrudan geçmek pratik görünebilir. Bir siparişi argüman olarak verdim:
public sealed record OrderLine(string Isbn, string Title, int Quantity, decimal UnitPrice);
public sealed record Order(int Id, string CustomerEmail, string ShippingAddress, List<OrderLine> Lines);
app.MapPost("/demo/entity", (IBackgroundJobClient jobs) =>
{
var order = new Order(44, "grace@example.com", "221B Baker Street, London",
[new("978-0441013593", "Dune", 1, 12.99m), new("978-0547928227", "The Hobbit", 2, 9.50m)]);
return jobs.Enqueue<OrderEmailJobs>(j => j.SendConfirmationForAsync(order, CancellationToken.None));
});
SELECT Id, Arguments FROM HangFire.Job WHERE Id = 7;
7|["{\"Id\":44,\"CustomerEmail\":\"grace@example.com\",\"ShippingAddress\":\"221B Baker Street, London\",\"Lines\":[{\"Isbn\":\"978-0441013593\",\"Title\":\"Dune\",\"Quantity\":1,\"UnitPrice\":12.99},{\"Isbn\":\"978-0547928227\",\"Title\":\"The Hobbit\",\"Quantity\":2,\"UnitPrice\":9.50}]}",null]
Siparişin tamamı, adresiyle ve satırlarıyla birlikte job tablosunda. Bunun üç sorunu var. Birincisi bayatlık: müşteri job çalışmadan adresini değiştirirse job eski adresi kullanır. İkincisi kişisel veri: bu tablo dashboard'da görünür ve job'lar başarıyla bittikten sonra bile varsayılan olarak bir süre daha saklanır. Üçüncüsü boyut: büyük object'ler storage'ı ve her retry'ı şişirir.
Daha kötüsü, scope'a bağlı bir nesneyi geçmek. DbContext'i argüman olarak vermeyi denediğimde job hiç oluşturulamadı:
using var db = new BookstoreDbContext(new DbContextOptionsBuilder<BookstoreDbContext>().UseSqlite("Data Source=:memory:").Options);
try
{
string id = BackgroundJob.Enqueue<ReportJobs>(j => j.RunAsync(db));
Console.WriteLine($"enqueued {id}");
}
catch (Exception ex)
{
Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
if (ex.InnerException is not null) Console.WriteLine($" inner {ex.InnerException.GetType().Name}: {ex.InnerException.Message}");
}
BackgroundJobClientException: Background job creation failed. See inner exception for details.
inner JsonSerializationException: Self referencing loop detected for property 'Context' with type 'BookstoreDbContext'. Path 'ChangeTracker'.
Kural basit: job'a id ver, job kendi scope'unda veriyi taze olarak yüklesin. Hangfire'ın kendi dokümantasyonu da argümanların küçük ve basit tutulmasını öneriyor.
Job'u Değiştirmek: Yeniden Adlandırma ve async void
Baştaki soruya dönelim. Uygulamayı yalnız client olarak (server'sız) çalıştırıp 43 numaralı sipariş için üç job yazdım, sonra SendInvoiceAsync'in adını SendInvoiceEmailAsync yapıp server'ı başlattım:
# server'sız, eski kod
$ curl -s -X POST http://127.0.0.1:17105/orders/43/confirm
{"emailJobId":"4","invoiceJobId":"5","reviewJobId":"6"}
# SendInvoiceAsync -> SendInvoiceEmailAsync, yeni sürüm server ile açılıyor
05:04:17.550 info: Bookstore.Api.LogEmailSender[0] Email to customer43@example.com: Order #43 confirmed
05:04:17.594 warn: Hangfire.AutomaticRetryAttribute[0] Failed to process the job '5': an exception occurred. Retry attempt 1 of 10 will be performed in 00:00:26. System.InvalidOperationException: The type `Bookstore.Api.OrderEmailJobs` does not contain a method with signature `SendInvoiceAsync(Int32, CancellationToken)` [...]
05:04:32.481 info: Bookstore.Api.LogEmailSender[0] Email to customer43@example.com: How was order #43?
Onay e-postası gitti, çünkü o method değişmedi. Continuation olan fatura job'u ise InvocationData'daki SendInvoiceAsync'i bulamadı ve InvalidOperationException ile düştü. Daha da önemlisi bu hata bir retry'a dönüştü: "Retry attempt 1 of 10 will be performed in 00:00:26". Hangfire'ın varsayılan AutomaticRetry filtresi her başarısız job'u 10 kez, artan aralıklarla yeniden dener; method geri gelmeyeceği için bu 10 deneme boşa gider ve job sonunda Failed durumuna düşer.
Rolling deploy sırasında aynı şey ters yönde de olur: yeni sürümün yazdığı job'u henüz güncellenmemiş eski bir server alır ve yeni method'u bulamaz. Güvenli yol method'ları sürümlemek: yeni method'u ekle, eskisini bir süre yeni method'u çağıran ince bir köprü olarak bırak, kuyrukta eski job kalmadığında sil. Parametre eklemek de aynı kurala tabi, çünkü InvocationData parametre tiplerini de saklıyor.
Bir de method'un dönüş tipi var. async void bir method'u kuyruğa yazmaya çalıştım:
public async void FireAndForgetVoid(int orderId) => await Task.Delay(10);
app.MapPost("/demo/async-void", (IBackgroundJobClient jobs) =>
{
try { jobs.Enqueue<OrderEmailJobs>(j => j.FireAndForgetVoid(1)); return Results.Ok(); }
catch (Exception ex) { return Results.Text($"{ex.GetType().Name}: {ex.Message}"); }
});
// NotSupportedException: Async void methods are not supported. Use async Task instead.
Hangfire bunu job oluşturulurken reddediyor; iyi ki, çünkü async void bir method'un ne zaman bittiğini ve hata verip vermediğini kimse bilemez. Job method'ları Task dönmeli.
Lisans: Core, Pro ve Ace
Hangfire'ın fiyatlandırma sayfası (yazının yazıldığı tarihte) dört sürüm listeliyor. Open sürüm ücretsiz; Hangfire Core LGPL (GNU Lesser General Public License) 3.0 lisanslı ve sayfaya göre ticari ve kapalı kaynak uygulamalarda kullanılabilir. Desteklenen storage'lar SQL Server ve In-Memory; PostgreSQL desteği topluluğun yazdığı Hangfire.PostgreSql paketinden geliyor (şu an 1.21.1).
Ücretli sürümler kuruluş başına yıllık: Startup 500 dolar ve Hangfire Pro'yu içeriyor; Pro, batch'ler (atomik olarak oluşturulan job grupları ve batch continuation'lar), Redis storage ve Windows performance counter'larını getiriyor. Business 1.500 dolar ve Pro'ya ek olarak Hangfire Ace'i içeriyor; Ace'in tek paketi Hangfire.Throttling ve mutex, semaphore ve rate limiter gibi eşzamanlılık sınırlayıcıları sunuyor. Enterprise 4.500 dolardan başlıyor. Fiyatlar değişebilir; satın almadan önce sayfayı kontrol et.
Üretimde Dikkat
Worker sayısını ve queue'ları işe göre ayır. Saniyeler süren e-posta job'ları ile dakikalar süren rapor job'larını aynı queue'ya koyarsan, birkaç rapor bütün worker'ları kapıp e-postaları bekletir. Uzun işleri ayrı bir queue'ya ve gerekirse ayrı bir server'a (ayrı process, ayrı WorkerCount) taşı.
Storage'ı veritabanının parçası olarak düşün. Hangfire tabloları uygulamanın veritabanındaysa job'lar ve sipariş verisi aynı backup'ta, aynı failover'da olur; ama yoğun job trafiği uygulama sorgularıyla aynı kaynakları paylaşır. Yük büyüdükçe ayrı bir veritabanı düşün.
Hata bildirimlerini kur. Varsayılan retry politikası ilk hatayı 10 denemenin arkasına saklar; job ancak saatler sonra Failed'a düşer. Kalıcı hataları retry'dan ayırmayı ve başarısız job sayısına alarm koymayı yedinci ve onuncu yazılarda yapacağız.
Sık Düşülen Tuzaklar
async void job. Hangfire job'u oluştururken NotSupportedException fırlatır; job method'ları Task dönmeli.
DbContext ya da entity'yi argüman olarak geçmek. DbContext serialize edilemez; entity ise bayat ve kişisel veri içeren bir kopya olarak saklanır. Yalnız id geç.
Recurring job'un id'sini değiştirip eskisini silmemek. AddOrUpdate id'ye göre çalışır; nightly-inventory-report'u nightly-stock-report yaparsan eskisi silinmez, iki tanım da her gece çalışır. Yeniden adlandırırken RecurringJob.RemoveIfExists("nightly-inventory-report") çağırmayı unutma.
Method imzasını kuyrukta job varken değiştirmek. Eski job'lar method'u bulamaz ve 10 deneme boyunca retry'da döner. Yeni method ekle, eskisini köprü olarak bırak.
Delayed job'dan saniye hassasiyeti beklemek. Scheduled job'lar varsayılan olarak 15 saniyede bir yoklanır.
Küçük Bir Deneme
★☆☆ SchedulePollingInterval'ı 1 saniyeye indir ve yorum isteği job'unun gecikmesini tekrar ölç. Bu ayarın veritabanına maliyeti ne?
★★☆ Fatura job'u için method sürümleme yap: SendInvoiceEmailAsync'i ekle, SendInvoiceAsync'i yeni method'u çağıran bir köprü olarak bırak ve yeniden adlandırma senaryosunu tekrar çalıştır. Kuyrukta eski job kalmadığını nasıl doğrularsın?
★★★ İki ayrı Hangfire server çalıştır: biri yalnız critical queue'yu 2 worker'la, diğeri default'u 10 worker'la dinlesin. 50 e-posta ve 5 uzun rapor job'u kuyruğa yaz; e-postaların gecikmesi tek server'lı kuruluma göre nasıl değişiyor?
Bir sonraki yazıda zamanlama tarafında daha güçlü olan araca geçiyoruz: Quartz.NET 4 ile job ve trigger ayrımı, PostgreSQL'de ActiveX Data Objects .NET (ADO.NET) JobStore, aynı veritabanını paylaşan iki node'luk cluster, misfire davranışları ve RFC (Request for Comments) 5545 recurrence kuralları.