Background Job'larda Sürüm Geçişleri: .NET 6→10 BackgroundService Değişiklikleri, .NET 11 ve Quartz 3→4
.NET 6 BackgroundService hatasında host'u durdurmaya başladı, ShutdownTimeout .NET 7'de 5'ten 30 saniyeye çıktı, .NET 8 lifecycle hook'larını getirdi, .NET 10 ExecuteAsync'in tamamını arka plana aldı: ilk await'ten önce fırlayan hata .NET 8'de process'i 134 koduyla düşürürken .NET 10'da loglanıp 0 koduyla çıkıyor. Quartz 3→4'te basit bir job tek bir CS0535 hatası verdi; asıl iş paketler, şema migration'ı ve değişen varsayılanlar.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; .NET 8 karşılaştırmaları .NET 8.0.25 runtime'ında, Quartz örnekleri Quartz.NET 3.22.9 ve 4.7.2 ile koşturuldu. .NET 11 maddeleri yazının yazıldığı tarihteki (RC (Release Candidate) dönemi) Microsoft Learn breaking change dokümanına dayanıyor.
Önce Tahmin Et
Bookstore'un .NET Core 3.1 zamanından kalma bir servisi var: Startup.cs'te AddHostedService ile kaydedilmiş, içinde bir System.Threading.Timer olan, her saniye sepet temizliği yapan bir IHostedService. Proje .NET 10'a taşınıyor. Kod derlenir mi? Bir temizlik turu 2,5 saniye sürerse turlar üst üste biner mi? Bir de Quartz tarafı: 3.x için yazılmış bir job'u paketi 4.7.2'ye yükseltip derlediğinde kaç hata alırsın?
Zaman Çizelgesi: .NET 6'dan .NET 11'e
Hosted service altyapısı her sürümde biraz değişti. Değişikliklerin çoğu bir davranış değişikliği: kod derlenmeye devam ediyor, ama farklı çalışıyor. Bu yüzden derleyici sana söylemiyor.
| Sürüm | Değişiklik | Etkisi |
|---|---|---|
| .NET 6 | BackgroundServiceExceptionBehavior, varsayılan StopHost | ExecuteAsync'te yakalanmayan hata loglanır ve host durur |
| .NET 7 | ShutdownTimeout varsayılanı 5 sn'den 30 sn'ye | Kapanışta işlere daha uzun süre tanınır |
| .NET 8 | IHostedLifecycleService, ServicesStartConcurrently, ServicesStopConcurrently, StartupTimeout | Başlangıç ve kapanışa ek hook'lar; isteğe bağlı paralel başlatma |
| .NET 10 | ExecuteAsync'in tamamı arka planda | İlk await'ten önceki kod startup'ı bloklamaz |
| .NET 11 | RunAsync/StopAsync çöken BackgroundService'te exception fırlatır | Process sıfırdan farklı kodla çıkar |
.NET 11'in değişikliği, Microsoft Learn'deki breaking change notuna göre ilk olarak Preview 3'te geldi ve 10 Kasım 2026'daki GA (General Availability) sürümüyle birlikte geliyor. Diğer satırları tek tek, mümkün olanları gerçek çıktıyla görelim.
.NET 6 ve .NET 7: Kaybolan Hata ve 5 Saniyelik Kapanış
.NET 6'dan önce bir BackgroundService'in ExecuteAsync'inden kaçan exception kayboluyordu: service sessizce duruyor, host çalışmaya devam ediyordu. Microsoft Learn'deki .NET 6 breaking change notu, bu durumda service'in "yanıt vermiyor gibi" göründüğünü yazıyor. .NET 6 ile exception loglanıyor ve varsayılan olarak host duruyor. Serinin ikinci yazısında bunun .NET 10'daki gerçek çıktısını görmüştük: hata loglandı, host durdu, process 0 koduyla çıktı. Eski davranışı bekleyen bir kod, yani "arka plandaki iş çökse de API (Application Programming Interface) yaşamaya devam etsin" varsayımı, .NET 6'ya geçince bütün uygulamayı düşürmeye başlar. Ignore eski davranışı geri getiriyor, ama doğrusu her turu kendi try/catch'iyle korumak.
Planlarda ve blog yazılarında sık geçen bir bilgiyi de düzeltelim: ShutdownTimeout'un 30 saniye olması .NET 6'da değil, .NET 7'de geldi. dotnet/runtime deposunda HostOptions.cs'in v6.0.0 etiketinde değer TimeSpan.FromSeconds(5), v7.0.0 etiketinde TimeSpan.FromSeconds(30). .NET 6'da çalışan bir servisin kapanışta 5 saniyede kesilen işleri .NET 7 ve sonrasında 30 saniye bekleyebilir. Bu genellikle iyi bir haber; ama Kubernetes'in terminationGracePeriodSeconds varsayılanı da 30 saniye olduğundan, ikinci yazıda konuştuğumuz bütçe çakışması da .NET 7 ile başladı.
.NET 8'den .NET 10'a: ExecuteAsync'in Senkron Kısmı
.NET 10'daki değişikliğin startup süresine etkisini ikinci yazıda ölçmüştük. Bir etkisi daha var ve migration sırasında daha tehlikeli: ilk await'ten önce fırlayan exception. Konfigürasyonu kontrol eden ve eksikse fırlatan bir service:
public sealed class ConfigCheckingService(IConfiguration config, ILogger<ConfigCheckingService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// ilk await'ten önce: .NET 8'de StartAsync'in içinde, .NET 10'da arka planda çalışır
string endpoint = config["Supplier:Endpoint"]
?? throw new InvalidOperationException("Supplier:Endpoint is not configured");
logger.LogInformation("Syncing from {Endpoint}", endpoint);
await Task.Delay(Timeout.Infinite, stoppingToken);
}
}
Aynı projeyi iki hedefle, configuration'da Supplier:Endpoint olmadan çalıştırdım:
## net8.0
05:31:53.339 fail: Microsoft.Extensions.Hosting.Internal.Host[11] Hosting failed to start System.InvalidOperationException: Supplier:Endpoint is not configured [...]
Unhandled exception. System.InvalidOperationException: Supplier:Endpoint is not configured
at Bookstore.Jobs.ConfigCheckingService.ExecuteAsync(CancellationToken stoppingToken) in /app/p02/Services.cs:line 56
at Microsoft.Extensions.Hosting.Internal.Host.<StartAsync>b__15_1(IHostedService service, CancellationToken token)
[...]
exit code 134
## net10.0
05:31:53.644 info: Microsoft.Hosting.Lifetime[14] Now listening on: http://127.0.0.1:17110
05:31:53.642 fail: Microsoft.Extensions.Hosting.Internal.Host[9] BackgroundService failed System.InvalidOperationException: Supplier:Endpoint is not configured [...]
05:31:53.654 info: Microsoft.Hosting.Lifetime[0] Application started. Press Ctrl+C to shut down.
05:31:53.654 crit: Microsoft.Extensions.Hosting.Internal.Host[10] The HostOptions.BackgroundServiceExceptionBehavior is configured to StopHost. [...]
05:31:53.654 info: Microsoft.Hosting.Lifetime[0] Application is shutting down...
05:31:53.658 RunAsync returned, exit code 0
exit code 0
.NET 8'de exception StartAsync'in içinden çıktı; host hiç başlamadı ve process işlenmemiş exception ile 134 koduyla (SIGABRT (abort signal)) düştü. .NET 10'da aynı exception arka planda yakalandı, "BackgroundService failed" olarak loglandı, Kestrel bir an için dinlemeye bile başladı, sonra StopHost davranışı host'u durdurdu ve process 0 ile çıktı.
Bunun pratik anlamı şu: .NET 8'de "eksik konfigürasyonda pod hiç ayağa kalkmaz, CrashLoopBackOff'a düşer" diye güvendiğin bir davranış .NET 10'da "pod kalkar, kendiliğinden ve başarı koduyla kapanır" olur. Orchestrator'ın yeniden başlatma politikası, deployment'ın hazır olma kontrolü ve alarm kuralların bu farkı görmeyebilir. Startup'ta mutlaka doğrulanması gereken konfigürasyonu BackgroundService'in içinde değil, options doğrulamasında (ValidateOnStart) ya da StartAsync'te yap. .NET 11 ile process'in bu senaryoda sıfırdan farklı kodla çıkması, farkın bir kısmını kapatacak.
Eski Kodu Okumak: IHostedService ve Timer
Eski dokümanlarda ve projelerde sık rastlanan kalıp:
// eski dokümanlardaki "IHostedService + Timer" kalıbı
public class CartCleanupService : IHostedService, IDisposable
{
private readonly ILogger<CartCleanupService> _logger;
private Timer? _timer;
private int _runs;
public CartCleanupService(ILogger<CartCleanupService> logger) => _logger = logger;
public Task StartAsync(CancellationToken cancellationToken)
{
_timer = new Timer(DoWork, null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
return Task.CompletedTask;
}
private void DoWork(object? state)
{
int run = Interlocked.Increment(ref _runs);
_logger.LogInformation("Cleanup run {Run} started on thread {Thread}", run, Environment.CurrentManagedThreadId);
Thread.Sleep(2500); // bir tur periyottan uzun sürüyor
_logger.LogInformation("Cleanup run {Run} finished", run);
}
public Task StopAsync(CancellationToken cancellationToken)
{
_timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
public void Dispose() => _timer?.Dispose();
}
.NET 10'da derleniyor ve çalışıyor. Ama çıktısı, bu kalıbın neden terk edildiğini gösteriyor:
05:32:16.840 info: Bookstore.Legacy.CartCleanupService[0] Cleanup run 1 started on thread 8
05:32:16.852 info: Microsoft.Hosting.Lifetime[0] Application started. Press Ctrl+C to shut down.
05:32:17.840 info: Bookstore.Legacy.CartCleanupService[0] Cleanup run 2 started on thread 10
05:32:18.840 info: Bookstore.Legacy.CartCleanupService[0] Cleanup run 3 started on thread 11
05:32:19.354 info: Bookstore.Legacy.CartCleanupService[0] Cleanup run 1 finished
05:32:19.841 info: Bookstore.Legacy.CartCleanupService[0] Cleanup run 4 started on thread 8
05:32:20.342 info: Bookstore.Legacy.CartCleanupService[0] Cleanup run 2 finished
05:32:20.831 info: Microsoft.Hosting.Lifetime[0] Application is shutting down...
İki sorun görünüyor. Birincisi turlar üst üste biniyor: her tur 2,5 saniye sürüyor, Timer ise bir öncekinin bitmesini beklemeden her saniye yeni bir turu başka bir thread'de başlatıyor; 18,840'ta dört tur aynı anda çalışıyordu. Microsoft Learn'deki hosted services sayfası da bunu açıkça yazıyor: Timer önceki DoWork çalışmalarının bitmesini beklemez. İkincisi kapanış: StopAsync yalnız timer'ı durduruyor, çalışan turları beklemiyor; 3. ve 4. turlar hiç bitmedi. Callback async olmadığı için iptal token'ı da yok.
Modern karşılığı, üçüncü yazıdaki BackgroundService + PeriodicTimer: tek bir döngü, turlar asla üst üste binmez, uzun bir turdan sonra en fazla bir tick hemen gelir, stoppingToken işin içine kadar taşınır. Eski bir servisi taşırken önce davranışı karar ver: turların paralel çalışması gerçekten isteniyor muydu, yoksa sadece fark edilmemiş miydi? Çoğu zaman ikincisi.
Startup.cs'te AddHostedService
Eski kodun iskeleti de çoğu zaman şöyle:
// .NET Core 3.1 / .NET 5 döneminin iskeleti: Program + Startup
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(web => web.UseStartup<Startup>())
.Build()
.Run();
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddHostedService<CartCleanupService>();
}
public void Configure(IApplicationBuilder app)
{
app.UseRouting();
app.UseEndpoints(e => e.MapGet("/", () => "ok"));
}
}
Bu iskelet de .NET 10'da derleniyor ve çalışıyor; Host.CreateDefaultBuilder ve ConfigureWebHostDefaults hâlâ destekleniyor. Taşımak zorunlu değil, ama yeni hosting modeline (WebApplication.CreateBuilder) geçerken hosted service kayıtları birebir taşınıyor: services.AddHostedService<T>() satırı builder.Services.AddHostedService<T>() olur. Kayıt sırasının başlangıç sırasını, tersinin de kapanış sırasını belirlediğini unutma; ConfigureServices'teki sırayı koru. İki hosting modelinin farkı ASP.NET Core Web API serisinin "ASP.NET Core'da Program.cs ve Hosting Modeli: WebApplication ve Environment'lar" yazısında.
Quartz 3'ten 4'e
Quartz.NET 4.0 büyük bir sürüm ve migration rehberi uzun. Önce gerçek bir deneme: 3.22.9 ile yazılmış, Quartz.Extensions.Hosting paketini kullanan tipik bir job:
// Quartz.Extensions.Hosting 3.22.9
builder.Services.AddQuartz(q =>
{
var jobKey = new JobKey("stock-report");
q.AddJob<StockReportJob>(o => o.WithIdentity(jobKey));
q.AddTrigger(t => t.ForJob(jobKey).WithCronSchedule("0/2 * * * * ?"));
});
builder.Services.AddQuartzHostedService(o => o.WaitForJobsToComplete = true);
public class StockReportJob(ILogger<StockReportJob> logger) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
await Task.Delay(100, context.CancellationToken);
logger.LogInformation("Stock report generated by Quartz {Version}", typeof(IJob).Assembly.GetName().Version);
}
}
Stock report generated by Quartz 3.22.9.0
Stock report generated by Quartz 3.22.9.0
Paketi Quartz.Extensions.Hosting 3.22.9'dan Quartz 4.7.2'ye çevirip derledim:
$ dotnet build
StockReportJob.cs(5,63): error CS0535: 'StockReportJob' does not implement interface member 'IJob.Execute(IJobExecutionContext, CancellationToken)'
Tek bir hata. AddQuartz, AddJob, AddTrigger ve AddQuartzHostedService çağrıları değişmeden derlendi. Hatayı düzeltmek için imzayı 4.x'e uydurdum:
public sealed class StockReportJob(ILogger<StockReportJob> logger) : IJob
{
public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken)
{
await Task.Delay(100, cancellationToken);
logger.LogInformation("Stock report generated by Quartz {Version}", typeof(IJob).Assembly.GetName().Version);
}
}
Stock report generated by Quartz 4.7.2.0
Bu kadar kolay görünmesi yanıltıcı; derleyicinin göstermediği değişiklikler daha önemli. Migration rehberinden en kritikleri:
- Paketler:
Quartz.Extensions.DependencyInjection,Quartz.Extensions.HostingveQuartz.Serialization.SystemTextJsonçekirdekQuartzpaketine taşındı; eski ID'lerin altında boş 4.x paketleri var ama kaldırılmalı. 3.x paketlerini bırakmakQuartzOptionsgibi tipler için CS0433 hataları verir. - Şema:
schema_30_to_40_upgrade_<database>.sqlzorunlu ve 3.x node'ları çalışırken uygulanabiliyor; index script'i son 3.x node'u durduktan sonra. Migrate edilmemiş şemayla 4.0 node'u başlamıyor ve eksik kolonu raporluyor. - Varsayılanlar:
quartz.configartık okunmuyor; varsayılanInstanceNameDefaultQuartzScheduler'danQuartzScheduler'a değişti ve persistent store satırları bu ada göre anahtarlandığı için açıkça ad vermemiş bir uygulama eski job'larını göremez. Ayarlanmamış bellek içi store'un misfire eşiği 60 saniyeden 5 saniyeye indi. - Serileştirme:
quartz.serializer.type = jsonartık System.Text.Json demek (3.x'te Newtonsoft'tu); binary serileştirme kaldırıldı ve 4.x eski binary blob'ları okuyamıyor, dönüşümü 3.x'teyken yapmak gerekiyor. - API:
StdSchedulerFactorykaldırıldı,Quartz.SpiQuartz.Extensibilityoldu,JobDataMap'in indexer'ı olmayan anahtardanullyerineKeyNotFoundExceptionfırlatıyor. - Ekosistem:
MassTransit.Quartz4.x'te çalışmıyor, rehber 3.x'te kalmayı söylüyor; Quartz 3 için yazılmış OpenTelemetry topluluk instrumentation'ı 4.x'te hiçbir span üretmiyor (onuncu yazı).
İptal token'ının parametreye taşınmasının bir yan faydası da var. Token'ı alt çağrıya vermeyen bir job varsayılan analiz ayarlarıyla uyarı üretmedi; AnalysisLevel=latest-recommended ile derleyince CA2016 geldi:
public sealed class IgnoringJob : IJob
{
public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken)
{
await Task.Delay(100);
}
}
$ dotnet build --no-incremental -p:AnalysisLevel=latest-recommended
Probe.cs(7,15): warning CA2016: Forward the 'cancellationToken' parameter to the 'Delay' method or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2016)
3.x'te token context.CancellationToken'daydı ve CA2016 onu göremiyordu; parametre olunca kural çalışıyor. Bu yüzden migration sırasında analiz seviyesini yükseltmek, kapanışta token'ı yok sayan job'ları bulmanın ucuz bir yolu.
Üretimde Dikkat
Quartz cluster'ını sırayla yükselt: önce şema migration'ı (3.x node'ları çalışırken), sonra node'lar, en son index script'i. Rehberin "Upgrading a running deployment" bölümü bu sırayı tarif ediyor; 4.x'in ara sürümleri de (4.6 ve 4.7) kendi zorunlu migration'larını getirdiği için her minor yükseltmede şema adımını kontrol et.
.NET sürüm geçişinde davranış farklarını test et, derlemeye güvenme. ExecuteAsync'in zamanlaması, exception davranışı ve kapanış süresi derleyicinin göremeyeceği değişiklikler. Startup'ta exception fırlatan, kapanışta token'ı yok sayan ve uzun tur atan service'lerin her biri için kısa bir entegrasyon testi, geçişin en ucuz sigortası.
Hedef framework'ü bil. Quartz 4 yalnız net10.0 hedefliyor; hâlâ .NET 8 ya da .NET Framework'te çalışan bir servis için 3.x'te kalmak zorunda. .NET 8'in desteği 10 Kasım 2026'da bitiyor; bu servislerin .NET 10'a geçişi zaten gündemde olmalı.
Sık Düşülen Tuzaklar
.NET 10'a geçince startup'ta fırlayan exception'ın hâlâ pod'u düşüreceğini varsaymak. Artık loglanıp 0 koduyla çıkılıyor; doğrulamayı ValidateOnStart'a ya da StartAsync'e taşı.
Eski Timer kalıbını olduğu gibi taşımak. Üst üste binen turlar ve beklenmeyen kapanış, yeni sürümde de aynı.
Quartz'ın varsayılan InstanceName'ine güvenmek. Ad değiştiği için 4.x, 3.x'in yazdığı satırları görmeyebilir; adı açıkça ver.
3.x paketlerini 4.x ile birlikte bırakmak. CS0433 hataları ya da daha kötüsü, eski bir pakete bağlı kalan bir entegrasyon.
MassTransit.Quartz kullanan bir uygulamayı 4.x'e yükseltmek. Rehbere göre çalışmıyor.
Küçük Bir Deneme
★☆☆ CartCleanupService'i BackgroundService + PeriodicTimer ile yeniden yaz ve aynı 2,5 saniyelik turla çalıştır. Log'da kaç tur görüyorsun, kapanışta yarım kalan tur var mı?
★★☆ ConfigCheckingService'teki doğrulamayı AddOptions<SupplierOptions>().ValidateDataAnnotations().ValidateOnStart()'a taşı ve uygulamayı net8.0 ile net10.0'da çalıştır. İki sürümde de process'in çıkış kodu ne?
★★★ Bir PostgreSQL veritabanında Quartz 3.22.9 ile bir cluster kur, birkaç job ve trigger oluştur, sonra 4.0 migration script'ini uygulayıp 4.7.2 node'u başlat. Eski trigger'lar devam ediyor mu? InstanceName'i açıkça vermediysen ne görüyorsun?
Bu yazıyla Background Job'lar ve Scheduled Job'lar serisi bitiyor. Task.Run'ın neden kaybolduğundan başlayıp BackgroundService'in yaşam döngüsüne, cron ve time zone'lara, Hangfire ve Quartz'a, retry ve idempotency'ye, çok instance'ta lock'lara, uzun job'lara ve görünürlüğe geldik. Ortak ders tek cümle: bir background job'u, her an yeniden başlatılabilecek, iki kez çalışabilecek ve sessizce durabilecekmiş gibi tasarla. Serinin devamında Backend kategorisindeki Message Queue'larla Çalışmak serisi, işin başka servislere mesaj olarak aktığı tarafı ele alıyor.