Ana içeriğe geç

Hangfire ve Quartz.NET'i Değiştirmeden İzlemek

· 7 dakikalık okuma
Ahmet Buğra Kösen
Software Developer

Önceki yazıda Milvaion'ı neden yazdığımı ve mimarisinin nasıl çalıştığını anlatmıştım. Sonunda da şöyle bir söz vermiştim: mevcut scheduler'ını hiç değiştirmeden Milvaion'ın izleme yeteneklerini nasıl ekleyebileceğine bakacağız.

Bu yazı tam olarak o. Ama önce şunu konuşalım: neden böyle bir şey yazma ihtiyacı duydum?


Migration Gerçekten Zor Bir Şey

Yeni bir open-source tool çıkardığında insanlara söylediğin şey aslında şu: "elindeki çalışan sistemi söküp benimkini tak."

Bu, kimsenin sabah kalkıp yapmak isteyeceği bir iş değil. Hele ki söz konusu olan arka plan işleriyse. Hangfire'da beş yıldır sorunsuz çalışan kırk tane job'ın varsa, "daha iyi bir dashboard" uğruna hepsini taşımak mantıklı bir risk/getiri hesabı değil. Ben de olsam yapmazdım.

Ama şunu da biliyorum: o kırk job'ın gece 3'te patladığında ne olduğunu görmek isteyen bir IT Operasyon ekibi var ve şu an ellerinde Seq sorgusundan başka bir şey yok.

Bu ikisi çelişmiyor aslında. İzleme ile çalıştırma ayrı şeyler. Job'ların Hangfire'da çalışmaya devam edebilir, ama o job'lara ne olduğu Milvaion'da toplanabilir.

External Scheduler Integration bunun için var.


Ne Yapıyor, Ne Yapmıyor

Netleştirelim, çünkü bu ayrım önemli:

Yapıyor:

  • Job'larını Milvaion dashboard'ında listeliyor
  • Her çalışmayı (occurrence) statüsü, süresi, exception'ı ve hangi worker'da koştuğuyla birlikte kaydediyor
  • Job'ın içinden yayımladığın logları occurrence'ın altında canlı gösteriyor
  • Metriklere dahil ediyor: EPM, ortalama süre, başarı oranı, statü sayaçları
  • Alarm kurallarını çalıştırıyor — job patladığında Slack/Teams/e-posta bildirimi

Yapmıyor:

  • Job'ı tetiklemiyor. Zamanlamayı hâlâ Hangfire ya da Quartz yapıyor.
  • Cron'unu değiştirmiyor. O senin scheduler'ının kararı.
  • Job'ı silmiyor, iptal etmiyor, duraklatmıyor.

Dashboard'da bu job'lar external rozetiyle görünüyor ve Trigger/Delete butonları kapalı geliyor. Edit ekranında da sadece Milvaion'a ait alanlar açık: display name, description, tags, zombie timeout. Cron, job data, execution timeout, concurrent policy gibi alanlar kilitli — çünkü onları Milvaion yönetmiyor.

Bunu bilinçli olarak böyle yaptım. Dashboard'dan cron'u değiştirebilseydin ama Hangfire'daki gerçek değer değişmeseydi, ekranda yalan söyleyen bir alan olurdu. İki kaynağın çeliştiği bir sistemden daha kötü bir şey yok.


Nasıl Çalışıyor?

Mimari tarafında sürpriz yok, ilk yazıdaki akışın kısaltılmış hali:

Uygulamanın içine bir SDK paketi ekliyorsun. Bu paket Hangfire tarafında bir job filter, Quartz tarafında bir job listener kaydediyor. Job başladığında ve bittiğinde bu filter/listener devreye giriyor, olayı RabbitMQ'ya bir mesaj olarak atıyor.

Milvaion tarafında ExternalJobTrackerService bu mesajları tüketiyor: ilk gördüğünde IsExternal = true işaretli bir ScheduledJob kaydı oluşturuyor, her çalışma için bir JobOccurrence açıyor ve iş bitince statüsünü, süresini, exception'ını güncelliyor.

Uygulaman (Hangfire/Quartz)
└── MilvaionJobFilter / MilvaionJobListener
└── RabbitMQ
└── ExternalJobTrackerService (Milvaion API)
├── ScheduledJob (IsExternal = true)
├── JobOccurrence (status, duration, exception)
└── Dashboard / Alerts / Metrics

Dikkat edilecek nokta şu: bu akış tek yönlü. Milvaion'dan senin uygulamana giden bir komut yok. Bu da entegrasyonun neden bu kadar düşük riskli olduğunun cevabı.

Peki ya entegrasyon patlarsa?

Bunu ayrıca konuşmak istiyorum çünkü ilk sorulan soru bu oluyor: "Milvaion'a bağlanamazsa job'larım durur mu?"

Hayır. Filter'ın içindeki her metot try-catch ile sarılı ve hata durumunda sessizce logluyor:

catch (Exception ex)
{
LogSafeError(ex, "OnPerforming", context?.BackgroundJob?.Job?.Type?.Name);
}

Ayrıca mesaj yayımlama işi fire-and-forget: job'ın thread'i RabbitMQ'yu beklemiyor. RabbitMQ kapalıysa, ağ koptuysa ya da Milvaion API'si down'sa senin job'ın hiçbir şey olmamış gibi çalışmaya devam eder. Sadece o çalışmanın kaydı dashboard'a düşmez.

Mevcut ve çalışan bir sisteme eklenti yaptığın için buradaki tasarım kararı çok net olmalıydı: izleme katmanı hiçbir koşulda çalıştırma katmanını etkilememeli.


Hangfire Entegrasyonu

Pratiğe geçelim. Önce paketi ekle:

dotnet add package Milvasoft.Milvaion.Sdk.Worker.Hangfire

Sonra iki satır:

using Hangfire;
using Milvasoft.Milvaion.Sdk.Worker.Hangfire.Extensions;

var builder = Host.CreateApplicationBuilder(args);

// 1. satır — Milvaion servislerini kaydet
builder.Services.AddMilvaionHangfireIntegration(builder.Configuration);

builder.Services.AddTransient<MyEmailJob>();

builder.Services.AddHangfire((sp, config) =>
{
config.UsePostgreSqlStorage(connectionString);

// 2. satır — filter'ı Hangfire'a tak
config.UseMilvaion(sp);
});

builder.Services.AddHangfireServer(options =>
{
options.WorkerCount = 4;
options.Queues = ["default", "critical"];
});

await builder.Build().RunAsync();

Hepsi bu. AddMilvaionHangfireIntegration core worker servislerini (heartbeat, statü bildirimi, log publisher) kaydediyor ama job consumer'ı kaydetmiyor — çünkü bu worker kuyruktan iş almayacak, sadece Hangfire'ın kendi işlerini raporlayacak. UseMilvaion ise MilvaionJobFilterGlobalJobFilters'a ekliyor.

Job kodunda tek bir satır değişmiyor. MyEmailJob neyse o kalıyor.

Kalan tek şey konfigürasyon:

{
"Worker": {
"WorkerId": "hangfire-worker",
"MaxParallelJobs": 128,
"RabbitMQ": {
"Host": "rabbitmq",
"Port": 5672,
"Username": "guest",
"Password": "guest",
"VirtualHost": "/"
},
"Redis": {
"ConnectionString": "redis:6379"
},
"Heartbeat": {
"Enabled": true,
"IntervalSeconds": 5
},
"ExternalScheduler": {
"Source": "Hangfire"
}
}
}

ExternalScheduler.Source alanı kritik. Milvaion bu job'ların nereden geldiğini buradan biliyor ve dashboard'da bu isimle etiketliyor.

Hangfire tarafında hangi olaylar yakalanıyor?

MilvaionJobFilter üç ayrı Hangfire arayüzünü birden implement ediyor:

ArayüzMetotNe oluyor
IClientFilterOnCreating / OnCreatedJob Milvaion'a kaydediliyor (ExternalJobRegistrationMessage)
IServerFilterOnPerformingOccurrence açılıyor, CorrelationId üretilip job parameter'a yazılıyor
IServerFilterOnPerformedSüre hesaplanıyor, statü ve exception yazılıyor
IElectStateFilterOnStateElectionJob DeletedState'e geçerse occurrence Cancelled işaretleniyor

OnPerforming'de üretilen CorrelationId, Milvaion_CorrelationId adıyla Hangfire'ın job parameter'larına yazılıyor. Birazdan bunu log basmak için kullanacağız.


Quartz.NET Entegrasyonu

Aynı hikâye, farklı bağlantı noktası. Quartz'ta filter yok, listener var:

dotnet add package Milvasoft.Milvaion.Sdk.Worker.Quartz
using Milvasoft.Milvaion.Sdk.Worker.Quartz.Extensions;
using Quartz;

var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddMilvaionQuartzIntegration(builder.Configuration);

builder.Services.AddQuartz(q =>
{
// Milvaion listener'larını devreye al
q.UseMilvaion();

var myJobKey = new JobKey("MyJob", "MyGroup");

q.AddJob<MyJob>(opts => opts.WithIdentity(myJobKey)
.WithDescription("My scheduled job"));

q.AddTrigger(opts => opts.ForJob(myJobKey)
.WithIdentity("MyJob-Trigger")
.WithCronSchedule("0 0 * * * ?"));
});

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

await builder.Build().RunAsync();

q.UseMilvaion() iki listener kaydediyor:

  • MilvaionSchedulerListener — scheduler ayağa kalktığında tanımlı job'ları Milvaion'a kaydediyor.
  • MilvaionJobListenerJobToBeExecuted ile occurrence açıyor, JobWasExecuted ile kapatıyor.

appsettings.json tarafında tek fark Source değeri:

"ExternalScheduler": {
"Source": "Quartz"
}

Quartz'ta CorrelationId, job parameter yerine MergedJobDataMap üzerinden geliyor.


Canlı Log Akışı

Buraya kadar anlattığım kısım tamamen pasif: job'ın kodunu hiç açmadan çalışma geçmişi, süreler ve exception'lar dashboard'a düşüyor.

Ama ilk yazıda "en çok değer katan şey canlı log akışı" demiştim. Onu istiyorsan job'ın içine küçük bir dokunuş gerekiyor — ILogPublisher enjekte ediyorsun:

using Hangfire.Server;
using Milvasoft.Milvaion.Sdk.Domain.JsonModels;
using Milvasoft.Milvaion.Sdk.Worker.RabbitMQ;

public class SendEmailJob(ILogger<SendEmailJob> logger, ILogPublisher logPublisher)
{
public async Task ExecuteAsync(PerformContext context, string recipient, CancellationToken ct)
{
// Filter'ın yazdığı tracking bilgisini oku
var correlationIdStr = context.GetJobParameter<string>("Milvaion_CorrelationId");
var workerId = context.GetJobParameter<string>("Milvaion_WorkerId") ?? "hangfire-worker";
var correlationId = Guid.TryParse(correlationIdStr, out var cid) ? cid : Guid.Empty;

await logPublisher.PublishLogAsync(correlationId, workerId, new OccurrenceLog
{
Level = "Information",
Message = $"{recipient} adresine e-posta gönderiliyor",
Timestamp = DateTime.UtcNow,
Category = "UserCode"
});

await SendAsync(recipient, ct);

// Job bitmeden buffer'ı boşalt
await logPublisher.FlushAsync(ct);
}
}

İki nokta:

  • CorrelationId olmadan log gitmez. Boş Guid gelirse publisher sessizce çıkar. Log görünmüyorsa ilk bakılacak yer burası.
  • FlushAsync şart. Loglar performans için buffer'lanıyor; job bitmeden flush etmezsen son satırlar kaybolabilir.

Kabul: bu, Milvaion'ın kendi IAsyncJob'ındaki context.LogInformation(...) kadar zarif değil. Ama pasif izleme için hiçbir şey yapmana gerek yok; log akışı isteğe bağlı bir üst basamak. Kademeli benimseme fikri tam olarak bu.


Denemek İçin Hızlı Yol

Kendi projene dokunmadan görmek istersen hazır image'lar var:

services:
hangfire-worker:
image: milvasoft/milvaion-sample-hangfire-worker:latest
environment:
- Worker__WorkerId=hangfire-worker-1
- Worker__RabbitMQ__Host=rabbitmq
- Worker__Redis__ConnectionString=redis:6379
- Worker__ExternalScheduler__Source=Hangfire
depends_on: [rabbitmq, redis]

quartz-worker:
image: milvasoft/milvaion-sample-quartz-worker:latest
environment:
- Worker__WorkerId=quartz-worker-1
- Worker__RabbitMQ__Host=rabbitmq
- Worker__Redis__ConnectionString=redis:6379
- Worker__ExternalScheduler__Source=Quartz
depends_on: [rabbitmq, redis]

Milvaion'ı docker compose up -d ile ayağa kaldırdıktan sonra bunları ekleyip dashboard'a bakman yeterli. Örnek worker'lar birkaç saniyede bir çalışan demo job'lar içeriyor, ekranda anında hareket görürsün.


Sık Karşılaşılan Üç Sorun

Job'lar dashboard'da hiç görünmüyor. Önce RabbitMQ bağlantısına bak (docker logs <worker> | grep -i rabbitmq), sonra ExternalScheduler.Source değerinin dolu olduğundan emin ol. Boşsa filter erken return ediyor.

Job görünüyor ama occurrence'lar Running'de asılı kalıyor. OnPerformed / JobWasExecuted mesajı ulaşmamış demektir. Uygulama iş ortasında kapandıysa normal — Milvaion'ın zombie detection'ı bir süre sonra bunları Failed'a çeker. Zombie timeout'u external job'larda düzenleyebildiğin birkaç alandan biri, tam da bu yüzden.

Loglar akmıyor. ILogPublisher enjekte edilmiş mi, CorrelationId gerçekten dolu mu, FlushAsync çağrılmış mı. Üçünden biri eksikse log görünmez.


Bu Entegrasyon Neyi Çözüyor?

Dürüst olmak gerekirse burada teknik olarak dâhiyane bir şey yok. Bir filter, bir listener, birkaç RabbitMQ mesajı. Kodun kendisi Milvaion'ın en basit parçalarından biri.

Ama ürün olarak en çok düşündüğüm parça buydu.

Çünkü Milvaion'ın karşısındaki asıl rakip Hangfire ya da Quartz değil — hiçbir şey yapmamak. İnsanların çoğu yeni bir tool'a bakıp "iyiymiş" deyip kapatıyor, çünkü denemenin maliyeti dashboard'ın getirisinden yüksek görünüyor. İki satır kodla mevcut sisteminden hiçbir şeyi riske atmadan sonucu görebiliyorsan, o hesap değişiyor.

Sonrası zaten organik: bir süre external mod'da izlersin, dashboard'ı kullanmaya alışırsın, alarmları kurarsın. Bir gün yeni bir job yazman gerektiğinde belki onu IAsyncJob olarak yazarsın. Belki hiç yazmazsın ve Milvaion senin için sadece bir observability katmanı olarak kalır. İkisi de benim için kabul edilebilir sonuçlar.

Milvaion GitHub'da Apache 2.0 lisansıyla açık kaynak. External scheduler entegrasyonunun tüm detayları için dökümana göz atabilirsin.

Bir sonraki yazıda görüşmek dileğiyle…