Ana içeriğe geç

"job-scheduler" ile etiketlenmiş 3 gönderi

Tüm Etiketleri Görüntüle

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…

Scheduler'ınıza Soru Sormak — Milvaion MCP Server

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

Serinin ilk yazısında Milvaion'ın çıkış noktasını, ikinci yazıda mevcut Hangfire/Quartz kurulumlarını değiştirmeden nasıl izleyebileceğini anlatmıştım.

Bu yazı, sisteme en son eklediğim ve açıkçası en çok tartıştığım özellik hakkında: MCP server.

Baştan uyarayım — bu bir "yapay zeka ile her şey çözüldü" yazısı değil. Aksine, bu özelliği yazarken en çok uğraştığım kısım ne yapabileceği değil, ne yapamayacağıydı.


Aynı Veriye İkinci Bir Kapı

İlk yazıda şunu söylemiştim: asıl değer job'ları çalıştırmakta değil, çalıştıktan sonra ne olduğunu görebilmekte. Dashboard tam da bunun için var ve üzerine en çok vakit harcadığım kısım.

MCP server onun yerine geçen bir şey değil. Dashboard hâlâ sistemi yönettiğin, workflow'unu kurduğun, job'ını düzenlediğin, canlı log akışını izlediğin yer — ve öyle kalıyor. MCP'nin yaptığı, aynı veriye ikinci bir kapı açmak: bakmak yerine sormak istediğin anlar için.

Aradaki fark şurada beliriyor. Dashboard bir soruyu cevaplamakta çok iyi. Bir teşhis ise genelde tek soru değil, birbirini tetikleyen bir soru zinciri:

Gece daily-invoice-export patlamış. Occurrence ekranında exception'ı okuyorsun — bir timeout. Sonra "bu ne zamandır böyle?" diye geçmişe bakıyorsun, salıdan beri. Sonra "salı günü worker ayakta mıydı?" diye worker ekranına, sonra "salı günü biri bu job'a dokunmuş mu?" diye activity log'a geçiyorsun.

Ekranların her biri sana doğru cevabı veriyor. Zorlanan taraf sen oluyorsun: dört ekrandan topladığın parçaları kafanda birleştirmek. Dördüncüde, birincide ne gördüğünü hatırlamaya çalışıyorsun.

Oysa aklındaki soru en baştan beri tek cümleydi: "daily-invoice-export salıdan beri patlıyor, logları oku ve ne değiştiğini söyle."

MCP server bu cümleyi doğrudan çalıştırılabilir hale getiriyor. Gezinmenin kendisini değil, gezinirken bağlamı taşıma yükünü ortadan kaldırıyor.

Kimin ne zaman hangi kapıyı kullanacağı da net: bir şeyi değiştirecekseniz dashboard, bir şeyi anlayacaksanız ikisi de iş görür — hangisi o an daha hızlıysa.


Kurulum

1. Read-only bir API key üret

Dashboard'dan bir API key oluştur ve sadece şunları ver:

  • ScheduledJobManagement.List, ScheduledJobManagement.Detail
  • FailedOccurrenceManagement.List
  • WorkerManagement.List
  • WorkflowManagement.List

Bu setle asistan her şeyi inceleyebilir, hiçbir şeyi değiştiremez. Çoğu insan için doğru varsayılan bu.

2. Sunucuyu editörüne tanıt

Milvaion remote HTTP bir MCP server, yani streamable HTTP transport destekleyen her client bağlanabilir. Claude Code'da dosya düzenlemeye bile gerek yok:

claude mcp add --transport http milvaion \
https://milvaion.sirketiniz.com/mcp \
--header "X-ApiKey: $MILVAION_API_KEY"

Cursor için .cursor/mcp.json:

{
"mcpServers": {
"milvaion": {
"type": "http",
"url": "https://milvaion.sirketiniz.com/mcp",
"headers": { "X-ApiKey": "your-api-key" }
}
}
}

Burada can sıkıcı bir gerçek var: her client aynı şeyi farklı anahtar isimleriyle istiyor. VS Code/Copilot üst seviye anahtar olarak mcpServers değil servers bekliyor. Windsurf url değil serverUrl diyor. Gemini CLI ise httpUrl.

"Bağlandı ama hiç tool görünmüyor" şikayetlerinin bir numaralı sebebi bu. İkinci sebebi ise Copilot'ta chat'in Agent moduna alınmamış olması — Ask modunda MCP tool'ları hiç görünmüyor.

Detaylı client matrisi ve Claude Desktop/ChatGPT'nin kendine has kısıtları için dökümanda ayrı bir bölüm var.

Anahtarı repoya koyma. Yukarıdaki client'ların hepsi ya ortam değişkeni genişletmeyi ya da çalışma anında sorma özelliğini destekliyor. İçinde canlı key olan bir config dosyası er ya da geç commit'lenir ve git geçmişine giren bir key'in tek çaresi yenisini üretip eskisini iptal etmektir.

3. Bir şey sor

Dün gece hangi job'lar patladı?

daily-invoice-export salıdan beri patlıyor. Logları oku ve ne değiştiğini söyle.

SendReportJob'ı çalıştırabilecek ayakta bir worker var mı?


Pratikte Neye Benziyor?

İlk soruyu sorduğunda asistan tek bir tool çağırmıyor. Tipik akış şöyle:

list_failures ile dead letter kayıtlarını çekiyor → aynı job'ın birden fazla kaydını görüp list_occurrences ile geçmişe bakıyor → patlamanın ne zaman başladığını buluyor → get_occurrence ile o çalışmanın loglarını ve exception detayını alıyor → list_workers ile worker'ın o sırada ayakta olup olmadığını kontrol ediyor → list_activity_logs ile o tarihte birinin job'a dokunup dokunmadığına bakıyor.

Yani dashboard'da ekran ekran gezerek yaptığın zinciri, sırayla kendisi kuruyor. Farkı, bağlamı kafanda taşımak zorunda olmaman.

Şunun altını çizeyim: bu, sihirli bir teşhis değil. Asistan logda ne yazıyorsa onu okuyor — dashboard'da senin gördüğün veriyle birebir aynı veri. Ama "bu exception'ı ilk gördüğüm tarih" ile "o tarihte activity log'da ne var" arasındaki bağlantıyı kurmak, sabah 9'da kahvesini içmemiş bir insandan daha hızlı yaptığı bir iş.

Cevabı aldıktan sonra genelde yine dashboard'a geçiyorsun zaten — çünkü job'ı düzeltmek, cron'u değiştirmek ya da tekrar tetiklemek orada yapılıyor.


Tool'lar ve İzin Modeli

40'tan fazla tool var ve hepsi bir izne bağlı. Kabaca gruplar:

GrupÖrnekler
Okumaget_overview, list_jobs, get_job, list_occurrences, get_occurrence, list_failures, list_workers, search_logs, summarize_logs, get_latest_report, list_activity_logs
Sistemget_system_health, get_queue_stats, get_database_statistics, get_configuration
Çalıştırmatrigger_job, cancel_occurrence, set_job_active, trigger_workflow
Düzenlemecreate_job, update_job, resolve_failures
Silmedelete_job, delete_occurrences, delete_failures, delete_worker

Buradaki tasarım kararı şu: hangi tool'ların görüneceğini sen seçmiyorsun, key'in ne yapabileceğini seçiyorsun.

Sadece List ve Detail izni verdiğin bir key'le asistanın önünde on beş okuma tool'u kalıyor, gerisi yok. İzni olmayan bir tool'u çağırdığında dönen hata da eksik iznin adını söylüyor — böylece model körlemesine tekrar denemek yerine sana "şu izni vermen lazım" diyebiliyor.

Bir de fark edilmesi zor ama önemli bir detay: get_occurrence logun tamamını değil, varsayılan olarak son 100 satırını döndürüyor (logLines ile artırabiliyorsun). Döngü içinde log basan bir job, aksi halde asistanın tüm context'ini tek çağrıda doldurabilir.


Prompt'lar — Beklediğimden Daha Önemli Çıktı

Sunucuyla birlikte üç tane prompt template geliyor:

PromptNe yapıyor
diagnose_jobPatlayan bir job'ı sırayla geziyor: failure'lar, örüntü, loglar, worker sağlığı, sonra son konfigürasyon değişiklikleri
overnight_reviewBelirli bir zaman aralığını inceleyip hataları tek tek listelemek yerine sebebe göre grupluyor
explain_workflowBir workflow'un adımlarını, dallanmasını ve veri akışını düz cümleyle anlatıyor

Bunlar ilk bakışta "hazır soru şablonu" gibi duruyor ama işlevleri farklı. Kendi haline bırakıldığında model genelde ilk okuduğu tool'dan başlıyor — list_jobs çağırıp job listesini geziyor, oysa sorunun cevabı list_failures'ta.

Prompt'lar modelin önüne bir çalışma sırası koyuyor. Aradaki fark, doğru cevap ile üç fazladan tool çağrısı sonrası doğru cevap arasındaki fark.


Bilinçli Olarak Dışarıda Bıraktıklarım

Kullanıcı yönetimi, roller, izinler, API key'ler, dispatcher kontrolü ve konfigürasyon yönetimi için hiç tool yok. Key'e ne yetki verirsen ver, bunlara MCP üzerinden erişilemiyor. Sebep basit: bir asistanın yeni bir API key üretebilmesi ya da kendi izinlerini genişletebilmesi, düşünmek bile istemediğim bir senaryo.

Workflow oluşturma ve düzenleme de yok. Condition'ları ve data mapping'leri olan yönlü bir grafiği tool çağrılarıyla kurmak teknik olarak mümkün ama doğru yer görsel builder. Bir asistanın "sanırım bu iki node'u bağlamak istedin" tahmini üzerine kurulmuş bir workflow, kimsenin production'da istemeyeceği bir şey.

Birkaç güvenlik detayı daha:

  • Yan etkiler kime ait olduğu yazılarak kaydediliyor. MCP üzerinden tetiklenen, iptal edilen ya da çözüldü işaretlenen her şey key'in adıyla loglanıyor. Geçmişte "bunu insan mı yaptı asistan mı" sorusunun cevabı duruyor.
  • Concurrency politikaları asla atlanmıyor. trigger_job her zaman force kapalı çalışıyor. Bir job'ın eşzamanlılık politikasını ezmek bilinçli bir insan eylemi olarak dashboard'da kalıyor.
  • Alanlar kazara temizlenemiyor. update_job sadece kendisine verilen argümanları değiştiriyor; verilmeyen alan boşaltılmıyor, olduğu gibi bırakılıyor.

Ve dürüst olmam gereken kısım:

Tool açıklamaları modele "bunu yapmadan önce onay al", "delete_job yerine set_job_active tercih et" diyor. Ama bunlar prompt, garanti değil. Modelin bunlara uyacağına dair bir taahhüt veremem.

Bu yüzden tavsiyem net: Create, Update ve Delete izinlerini, aynı yetkiyi bir insana dashboard üzerinden vermekten rahatsız olmayacağın yerlerde ver. Trigger gerçek işin çalışmasına sebep olur. Delete geri alınamaz. Geri kalan her durumda doğru cevap read-only bir key.


Çalıştığını Doğrulamak

/mcp HTTP üzerinden JSON-RPC konuşuyor, yani bir client'a hiç bulaşmadan curl ile test edebilirsin:

curl -X POST http://localhost:5000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "X-ApiKey: $MILVAION_API_KEY" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Header'ı çıkardığında aynı isteğin 401 dönmesi, endpoint'in korunduğunun en hızlı doğrulaması.

Sunucu stateless modda çalışıyor; herhangi bir API replikası herhangi bir isteği karşılayabiliyor, load balancer arkasında sticky session gerekmiyor. Bunun bedeli, kalıcı session gerektiren sampling ve elicitation gibi özelliklerin desteklenmemesi.


Sonuç

MCP server'ı yazarken kendime sorduğum soru "yapay zekayı nasıl eklerim" değildi. Soru şuydu: kafamda tek cümle olan bir soruyu, gerçekten tek cümlede sorulabilir hale nasıl getiririm?

Cevabın MCP olması biraz da zamanlama meselesi — protokol ortada, ekiplerin çoğunda zaten Claude Code ya da Cursor açık. Milvaion'ın yapması gereken tek şey, verisini bu ekosisteme düzgün bir şekilde açmaktı. Kendi LLM entegrasyonunu yazmak yerine veri kaynağı olmak, hem daha az kod hem daha az sorumluluk.

İşin ilginç yanı şu: bu özelliği yazarken harcadığım zamanın çoğu tool'ları eklemekle değil, hangilerini eklememem gerektiğine karar vermekle geçti. Bir scheduler'a doğal dil arayüzü açmak kolay; onu production'da güvenli tutmak asıl iş.

Milvaion GitHub'da Apache 2.0 lisansıyla açık kaynak. MCP server'ın tüm tool listesi, client konfigürasyonları ve güvenlik modeli için dökümana göz atabilirsin.

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

Neden Bir Distributed Job Scheduler Yazdım?

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

.NET tarafında arka plan işleri denince akla gelen ilk iki isim belli: Hangfire ve Quartz.NET. İkisi de olgun, ikisi de yıllardır sahada. Peki ortada bunlar varken neden oturup bir job scheduler yazdım?

Kısa cevap: ikisiyle de bir sorunum yoktu. Sorun, işlerin nerede çalıştığıyla başladı. Ama orada bitmedi — asıl uzun hikâye, o ilk sorunu çözdükten sonra ortaya çıkan eksiklerdi.

Bu yazıda önce o ilk sorunu anlatacağım, sonra Milvaion'ın buna nasıl bir cevap verdiğine bakacağız.


Problem: Job'lar Uygulamanın İçinde Çalışıyor

Hangfire'ı ya da Quartz'ı bir projeye eklediğinde, scheduler senin uygulamanın process'inin içinde yaşar. Zamanı gelen job, aynı process'te, aynı thread pool'da çalışır.

Bu, işlerin çoğu için gayet iyidir. Ta ki şunlardan biri olana kadar:

  • Uzun süren bir job diğerlerini bekletir. Gece çalışan rapor job'ın iki saat sürüyorsa, o iki saat boyunca thread pool'dan pay alır.
  • Patlayan bir job scheduler'ı da götürür. Job'ın içinde yakalanmamış bir exception ya da bir memory leak, sadece o job'ı değil uygulamanın tamamını etkiler.
  • Farklı işler farklı donanım ister. ML modeli çalıştıran bir job GPU isterken, e-posta gönderen bir job'ın 128 MB RAM'i vardır. İkisi aynı process'te yaşıyorsa, en pahalı olanın ihtiyacına göre ölçeklenirsin.
  • Job'lar API ile birlikte ölçeklenir. API'ye gelen trafik arttı diye pod sayısını üçe çıkardığında, job'ların da üçe çıkar. Genelde istediğin bu değildir.

Benim durumumda dört maddenin dördü de vardı. Bir noktada şunu fark ettim: aslında istediğim şey job'ların nerede çalışacağına ayrı karar verebilmekti.


Milvaion Nedir?

Milvaion, .NET 10 üzerine yazılmış, açık kaynak (Apache 2.0) bir distributed job scheduling sistemi. Temel fikri tek cümleyle şu:

Job'ın ne zaman çalışacağına karar veren yer ile onu çalıştıran yer aynı process olmak zorunda değil.

Bunu ikiye ayırıyor:

  • Scheduler (API): Cron ifadelerini okur, zamanı gelen job'ı tespit eder, kuyruğa atar. Dashboard'u da o barındırır.
  • Worker: Kuyruktan mesajı alır, senin IAsyncJob kodunu çalıştırır, sonucu geri bildirir.

Aralarında RabbitMQ, Redis ve PostgreSQL var.

Milvaion Dashboard

Bu ayrımın pratikte karşılığı şu: worker'ı ayrı deploy edersin, ayrı ölçeklersin, ayrı donanıma koyarsın. GPU isteyen job'lar için GPU'lu worker, e-posta için 128 MB'lık worker. Bir worker çökerse scheduler etkilenmez, kuyruktaki mesaj bekler.


Temel Kavramlar

Milvaion'da dolaşırken karşına çıkacak dört kelime var, baştan netleştirelim:

KavramNe Demek
JobTekrarlayan ya da tek seferlik bir çalışma tanımı. "Her sabah 9'da rapor gönder" bir job'dır.
Worker JobIAsyncJob implement eden C# sınıfın. Asıl işi yapan kod.
OccurrenceBir job'ın tek bir çalışması. Statüsü, süresi ve logları vardır.
WorkerJob'ları çalıştıran process.

Job bir tanım, occurrence ise o tanımın bir kez koşması. Dashboard'da "bu job 340 kere çalışmış, 3'ü patlamış" derken bahsedilen şey occurrence'lar.


Nasıl Çalışıyor?

Akış şöyle işliyor:

  1. Worker Auto Discovery senin worker'ını ve içindeki job sınıflarını otomatik keşfeder.
  2. Dashboard'dan ya da REST API'den bir job oluşturursun.
  3. Scheduler bunu PostgreSQL'e yazar ve bir sonraki çalışma zamanıyla Redis ZSET'e ekler.
  4. Dispatcher Redis'i kontrol eder, zamanı gelenleri bulur.
  5. Zamanı gelen job'lar routing key ile RabbitMQ'ya publish edilir.
  6. Worker mesajı alır, senin IAsyncJob kodunu çalıştırır.
  7. Worker durum ve logları RabbitMQ üzerinden geri bildirir.
  8. Scheduler sonucu kaydeder ve SignalR ile dashboard'a anlık olarak yansıtır.

Zamanlama için Redis ZSET kullanmamın sebebi, "şu ana kadar zamanı gelmiş job'ları getir" sorgusunun ZSET'te son derece ucuz olması. Veritabanını her saniye yoklamak yerine Redis'e soruyorum.


Bir Worker Yazmak

Teoriyi bir kenara bırakıp koda bakalım. Önce template'i kuralım:

dotnet new install Milvasoft.Templates.Milvaion
dotnet new milvaion-console-worker -n MyCompany.MyWorker

Sonra bir job yazalım:

using Milvasoft.Milvaion.Sdk.Worker.Abstractions;

public class SendReportJob(IReportService reportService) : IAsyncJob
{
public async Task ExecuteAsync(IJobContext context)
{
context.LogInformation("Rapor hazırlanıyor...");

// Job'a dashboard'dan girilen JSON payload'ı tipli olarak alıyoruz
var data = context.GetData<ReportRequest>();

await reportService.GenerateAsync(data, context.CancellationToken);

context.LogInformation("Rapor gönderildi.");
}
}

Dikkat edilecek üç nokta var:

  • Constructor injection çalışıyor. Worker normal bir .NET host olduğu için, DI container'ına ne koyduysan job'ın içinde kullanabilirsin.
  • context.LogInformation ile yazdığın loglar dashboard'da o occurrence'ın altında, canlı olarak görünür. Teknik loglar ayrıca Seq'e gider.
  • context.CancellationToken önemli. Job'ı dashboard'dan iptal ettiğinde ya da timeout'a düştüğünde tetiklenen token bu. Uzun süren işlerde bunu aşağıya geçirmezsen job iptal edilemez.

Worker'ı çalıştırdığında auto discovery devreye girer ve SendReportJob dashboard'da seçilebilir hale gelir. Ayrıca bir kayıt işlemi yapmana gerek yok.


Asıl Mesele Distributed Olması Değildi

Buraya kadar anlattığım şey Milvaion'ın çıkış noktasıydı. Ama dürüst olayım: eğer tek yaptığı job'ları ayrı process'te çalıştırmak olsaydı, muhtemelen bu yazıyı yazmaya değmezdi.

Scheduler'ı ayırdıktan sonra fark ettiğim şey şu oldu — asıl zamanı yiyen kısım job'ın nerede çalıştığı değil, çalıştıktan sonra ne olduğuydu. Job patladı, e-posta geldi, sonra ne? Logu nerede? Hangi worker'daydı? Dün de patlamış mıydı? Bunu kim değiştirmiş?

Hangfire'da ve Quartz'ta bu soruların cevabı genelde "Seq'e bak" ya da "veritabanına gir" gibi daha teknik bilgi sahibi insanlara yönelik oluyor. Fakat örneğin mevcut şirketimde IT Operasyon ekibimizin Hangfire altyapısında çalışan mevcut scheduler'ımızı anlamakta ve kullanmakta zorlandığını gördüm. Zamanla ortaya çıkan özelliklerin çoğu bu boşluğu kapatmak için yazıldı. Bu tarz tam kapsamlı piyasada gördüğüm sadece temporal.io oldu. Bende o devasa ekosisteme girmek ve öğrenmek yerine sfırdan, daha lightweight, developer friendly ve kontrolün tamamen bende olduğu bir tool geliştireyim dedim :)

Dashboard

Bu, üzerinde en çok vakit harcadığım kısımlardan biri. Quartz.NET'in hazır bir arayüzü yok — üçüncü parti bir şey kurarsın ya da kendin yazarsın. Hangfire'ın var ve gayet iş görüyor, ama kapsamı belli: job listesi, retry, recurring job'lar.

Milvaion'ın dashboard'ı şunları yapıyor:

  • Canlı log akışı. Job çalışırken context.LogInformation satırları SignalR üzerinden ekrana düşüyor. Bitmesini bekleyip Seq'e gitmiyorsun.
  • Occurrence geçmişi. Her çalışmanın süresi, statüsü, exception'ı, hangi worker'da koştuğu. Filtrelenebilir, cursor pagination'lı — 30 bin kayıtlı bir job'da da açılıyor.
  • Worker sağlığı. Hangi worker ayakta, kaç job çalıştırıyor, kapasitesi ne, heartbeat'i ne zaman geldi. Mcp server ile bu soruların cevabını yapay zeka ile alabilir ve tüm sistemi yönetebilirsin ;)
  • Job'ı ekrandan düzenleme. Cron'u değiştir, duraklat, tetikle, job data'sını güncelle. Deploy gerekmiyor.
  • Enterprise yönetim. RBAC based role yönetimi, kullanıcı yönetimi, auditing, api key yönetimi, mcp server, failure takibi gibi tüm sistemi arayüz üzerinden no-code yönetmenizi sağlayan bir çok ekran mevcut.

Bunlar tek tek "vay be" dedirtecek şeyler değil. Ama bir job gece 3'te patladığında hepsinin aynı ekranda olmasıyla olmaması arasında ciddi fark var.

Workflow Engine (DAG)

Hangfire'da ContinueJobWith var, yani "bu bitince şunu çalıştır" zinciri kurabiliyorsun. Quartz'ta bunun karşılığı yok, kendin kurgularsın.

Aslında workflow engine geliştirmeyi hiç istemiyordum çünkü n8n gibi robust toollar zaten piyasada mevcut fakat sisteme job chaning eklenmeliydi ve job chaning'in ucu eninde sonunda buraya çıkıyor. E madem job chaning yapacağız düzgün yapalım dedim :)

Milvaion'da görsel bir DAG builder var. Adımları sürükleyip bağlıyorsun; condition node ile koşula göre dallanıyor, merge node ile dallar birleşiyor, adımlar arasında data mapping ile bir adımın çıktısını diğerine bağlıyorsun. Bir adım patladığında hangi adımların çalışmadığı ekranda duruyor.

"Önce veriyi çek, başarılıysa işle, hata varsa alarm at, ikisinden sonra raporu gönder" gibi bir akış, üç ayrı job ve aralarında elle yazılmış kontrol koduyla uğraşmadan kuruluyor.

Alerting

Job patladığında birinin haberi olması lazım. Milvaion'da alarm kanalları built-in: Google Chat, Microsoft Teams, Slack, e-posta ve uygulama içi bildirim.

Hangfire ve Quartz tarafında bunu genelde kendin yazarsın — bir IJobFilter ya da bir listener, sonra HTTP client, sonra rate limiting derken küçük bir proje olur.

Auto Disable

Bu, en sevdiğim küçük özellik. Sürekli patlayan bir job'ı, belirlediğin eşikten sonra otomatik olarak devre dışı bırakıyor.

Neden önemli: connection string'i bozulmuş bir job her 5 dakikada bir çalışıp gece boyunca 96 kere patlar ve 96 alarm üretir. Sabah geldiğinde inbox'ında 96 mail olur ve içlerinden gerçekten önemli olanı bulamazsın. Auto disable "3 kere üst üste patladıysa dur, birine haber ver" diyor.

Eşiği ve failure window'u job bazında ayarlıyorsun — yani "son 30 dakika içinde 3 kere" diyebiliyorsun ki geçen haftadan kalma hatalar sayıya dahil olmasın.

Zombie Detection ve Timeout'lar

Worker çöktüğünde Running statüsünde asılı kalan occurrence'lar oluşur. Milvaion bunları tespit edip Failed'a çekiyor. İki ayrı timeout var:

  • Execution timeout: Job şu kadar saniyeden uzun sürerse worker cancellation token'ı tetikler.
  • Zombie timeout: Job şu kadar dakikadır kuyrukta ya da çalışıyor görünüyorsa, artık ölmüş kabul edilir.

Concurrent Execution Policy

Bir job'ın önceki koşusu hâlâ devam ederken zamanı tekrar geldiğinde ne olacak? Milvaion'da bunu job bazında seçiyorsun: yenisini atla, sıraya al ya da paralel çalıştır.

Hangfire'da bunu DisableConcurrentExecution attribute'u ile kod tarafında yaparsın, Quartz'ta [DisallowConcurrentExecution] ile. İkisi de derleme zamanı kararı; Milvaion'da ekrandan değiştiriyorsun.

RBAC, Kullanıcılar ve API Key'ler

Dashboard'ı ekibe açtığın anda "kim neyi silebilir" sorusu geliyor. Milvaion'da rol tabanlı yetkilendirme var ve granularity job/worker/dashboard seviyesinde. Birine sadece "görüntüleme" yetkisi verip production'a dokunamamasını sağlayabiliyorsun.

Bunun yanında API key desteği var: CI pipeline'ları, script'ler ve otomasyon için, kullanıcı hesabı olmadan. Key'in ne yapabileceğini yine permission bazında kısıtlıyorsun, iptal edilebiliyor, son kullanma tarihi verilebiliyor.

Hangfire'ın dashboard'ı varsayılan olarak yetkilendirmesiz gelir; IDashboardAuthorizationFilter yazarsın. Quartz'ta ortada bir dashboard olmadığı için soru da yok.

Metrik Raporları

Arka planda çalışan bir reporter worker düzenli olarak rapor üretiyor: hata oranı trendi, süre yüzdelikleri (p50/p95/p99), en yavaş job'lar, worker throughput'u ve doluluk oranı.

Bunlar dashboard'da grafik olarak duruyor. "Bu job son bir haftada yavaşladı mı" sorusuna bakarak cevap verebiliyorsun, sorgu yazmadan.

MCP Server — Scheduler'a Soru Sormak

Bu, en yeni eklenen şey. Milvaion bir MCP (Model Context Protocol) server olarak da çalışıyor. Claude Code, Cursor veya GitHub Copilot'ı Milvaion'a bağlayıp scheduler'ına düz cümleyle soru sorabiliyorsun.

40'tan fazla tool var: job listeleme, execution geçmişi, log okuma, dead letter kayıtları, worker sağlığı, tetikleme, duraklatma, düzenleme. Her biri kendi yetkisinin arkasında — sadece okuma yetkisi verdiğin bir API key'le asistan her şeyi inceleyebilir ama hiçbir şeyi değiştiremez.

Önemli bir not: Milvaion burada veri kaynağı. Sunucu tarafında hiçbir model sağlayıcı anahtarı tutulmuyor, dışarıya hiçbir çağrı yapılmıyor. Model senin editöründe, senin aboneliğinle çalışıyor.

Pratikte şuna benziyor: "dün gece hangi job'lar patladı ve neden" diye soruyorsun, asistan list_failures ile başlayıp get_occurrence ile logları çekiyor ve sana özetliyor.

Hangfire ve Quartz'ı İzlemek

Sonuncusu biraz ters köşe: Milvaion, Hangfire ve Quartz.NET kurulumlarını izleyebiliyor.

İki satır kodla mevcut scheduler'ını read-only şekilde Milvaion'a bağlıyorsun. Job'ların yine Hangfire'da çalışmaya devam ediyor, ama execution geçmişi, loglar, metrikler ve alarmlar Milvaion dashboard'ında toplanıyor. Migration yok, job kodun değişmiyor.

Bunu şunun için yazdım: Milvaion'un amacı hiçbir zaman sektör standardı olmuş Quartz ve Hangfire'a rakip olmak değildi. Bu rekabete girmekte yeni çıkmış bir open-source tool açısından anlamsız zaten. Hali hazırda sistemlerinde bu kütüphaneleri kullanan kişilerin Milvaion migration'ı zor olacak, bu yüzden bu geçişi organik bir şekilde yapabilmek ve Milvaion'un yeteneklerini kullanıcılara gösterecek bir onboarding amaçlı yazıldı bu entegrasyon.


Peki Ya Güvenilirlik?

Dağıtık bir sisteme geçtiğinde "mesaj kayboldu mu?" sorusu kaçınılmaz olarak gelir. Milvaion tarafında bunun cevapları şunlar:

  • At-least-once delivery: RabbitMQ manuel ACK kullanılıyor. Worker işi bitirmeden ACK göndermiyor, dolayısıyla worker ortasında çökerse mesaj kuyruğa geri düşer.
  • Otomatik retry: Exponential backoff ile. Kaç kere deneneceğini job bazında ayarlayabilirsin.
  • Dead Letter Queue: Retry hakkı biten job'lar DLQ'ya düşer, kaybolmaz. Dashboard'da ayrı bir ekranda listelenir.
  • Offline resilience: Worker RabbitMQ'ya ulaşamazsa sonuçları lokal SQLite'a yazar, bağlantı gelince gönderir.

Dikkat Edilmesi Gerekenler

Dürüst olmak gerekirse Milvaion her senaryo için doğru araç değil. Şu durumlarda başka bir şey kullanmalısın:

  1. Tek uygulama, tek sunucu. PostgreSQL + Redis + RabbitMQ üçlüsünü ayağa kaldırmanın maliyeti, kazandığından fazlaysa Hangfire çok daha doğru tercih.
  2. Sub-second zamanlama. Dispatcher en az saniyede bir kontrol ediyor. Milisaniye hassasiyeti gerekiyorsa buraya bakma.
  3. Event işleme. "Şu event gelince şunu çalıştır" ihtiyacın varsa bu bir scheduler işi değil; Kafka ya da benzeri bir şey daha uygun.
  4. .NET Framework. Milvaion .NET 10 hedefliyor. Legacy bir uygulaman varsa Hangfire ve Quartz çok daha geniş TFM desteği sunuyor.

Buna karşılık şu durumlarda gerçekten işe yarıyor: job'ların birden fazla servise dağılmışsa, uzun süren işlerin varsa, farklı job'lar farklı donanım istiyorsa, modern bir uygulama ve mimari istiyorsan, ya da denetim için tam bir çalışma geçmişi tutman gerekiyorsa.


Sonuç

Milvaion'ı yazma sebebim "daha iyi bir Hangfire" yapmak değildi. Zamanlama ile çalıştırmayı ayırmak istedim, çünkü elimdeki problem tam olarak oydu.

Ama işin bana öğrettiği şey şu oldu: o ayrımı yapmak işin sadece başlangıcıydı. Asıl değer, job'ları çalıştırdıktan sonra onlara ne olduğunu görebilmekte — dashboard'da, alarmlarda, raporlarda, auto disable gibi küçük ama gece uykunu kurtaran detaylarda.

Ortaya çıkan şey şu an .NET 10 üzerinde çalışıyor, Apache 2.0 lisanslı ve GitHub'da açık kaynak. Docker Compose ile beş dakikada ayağa kalkıyor:

git clone https://github.com/Milvasoft/milvaion.git
cd milvaion
docker compose up -d

Dashboard http://localhost:5000 adresinde seni bekliyor olacak.

Tüm özellikler için hemencecik ayaklandırıp inceleyebilirsin ya da dökümana bir göz at derim.

Bu yazıda temel mimariden ve özelliklerden bahsettim. Serinin bir sonraki yazısında, mevcut Hangfire ya da Quartz.NET kurulumunu hiç değiştirmeden Milvaion'ın izleme yeteneklerini nasıl ekleyebileceğine bakacağız — çünkü Milvaion'ı benimsemek için migration yapmak zorunda değilsin.

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