Mert Özen Her Satırda Daha İleri
Backend .NET'te E-posta, PDF, Excel ve Dosya İşleri — #09

Uçtan Uca: Bookstore Sipariş Belgeleri — Fatura PDF'i, Onay E-postası ve Aylık Excel Raporunun Job Zinciri

Mert Özen 15 Kas 2026 11 dk 8 görüntülenme
Uçtan Uca: Bookstore Sipariş Belgeleri — Fatura PDF'i, Onay E-postası ve Aylık Excel Raporunun Job Zinciri

Sipariş belgelerini Hangfire ile iki adımlı bir zincire bağla: fatura job'ı PDF'i storage'a yazar, continuation onay e-postasını outbox'a koyar; her adımın anahtarı yazdığı şeyin kendisi olduğu için tekrarlar zararsızdır. Bookstore'da storage kapalıyken yalnız fatura adımı tekrar denendi, aynı sipariş iki kez geldiğinde tek PDF ve tek e-posta çıktı; aylık rapor İstanbul saatiyle recurring job.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Hangfire 1.8.25, Hangfire.Storage.SQLite 0.4.3, QuestPDF 2026.9.2, Azure.Storage.Blobs 12.30.1, Mailpit 1.33.1 ve Azurite 3.37.0 ile derlenip denenmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için serinin parçalarını bu yazıda birleştiriyoruz: bir sipariş verildiğinde fatura PDF'i üretilecek, storage'a yazılacak ve PDF ekli bir onay e-postası gidecek. Aylık satış raporu da her ayın ilk günü kendiliğinden hazırlanacak. Üç soru:

Birincisi: fatura job'ı çalışırken storage kapalı. Hangfire hangi adımı tekrar dener ve e-posta adımı bu sırada ne yapar?

İkincisi: aynı sipariş için OrderPlaced iki kez geldi. Kaç PDF üretilir, müşteriye kaç e-posta gider?

Üçüncüsü: rapor job'ı kuyruğa girdi ve API hemen ardından kapandı. Yeniden açılınca ne olur?

Cevaplar gerçek koşudan. İlkinde yalnız fatura adımı 5 ve 30 saniye arayla tekrar deneniyor; e-posta adımı bu sürede Awaiting durumunda bekliyor ve storage açılınca zincir kaldığı yerden tamamlanıyor. İkincisinde tek PDF ve tek e-posta: ikinci koşu log'a "already stored" ve "already queued" yazıyor. Üçüncüsünde job veritabanında Enqueued olarak bekliyor ve uygulama açılınca çalışıyor.

Zincirin Parçaları

09-job-chain-tr.png

Zincirin her adımı daha önceki bir yazıda yazıldı. Faturayı beşinci yazıdaki InvoiceArchive üretiyor, sekizinci yazıdaki IFileStore saklıyor, e-postayı üçüncü yazıdaki outbox ve dispatcher gönderiyor. Bu yazının yeni kodu adımları birbirine bağlayan iki job method'u:

// Anahtar: invoices/{yıl}/{faturaNo}.pdf dosya yolu; ikinci koşu dosyayı bulur ve hiçbir şey render etmez
public async Task<string> GenerateInvoiceAsync(int orderId, CancellationToken cancellationToken)
{
    InvoiceModel invoice = await invoices.GetAsync(orderId, cancellationToken);
    bool existed = await archive.ExistsAsync(invoice, cancellationToken);
    string path = await archive.EnsureStoredAsync(invoice, cancellationToken);
    logger.LogInformation("Invoice for order {OrderId}: {Path} ({State})", orderId, path, existed ? "already stored" : "rendered");
    return path;
}

// Anahtar: outbox'ta "order-confirmed:{orderId}"; ikinci koşu satırı bulur ve hiçbir şey kuyruğa koymaz
public async Task QueueConfirmationEmailAsync(int orderId, CancellationToken cancellationToken)
{
    InvoiceModel invoice = await invoices.GetAsync(orderId, cancellationToken);
    string path = InvoiceArchive.PathFor(invoice);
    ...
    bool queued = await outbox.EnqueueAsync(db, $"order-confirmed:{orderId}", message,
        new StoredAttachment(path, $"invoice-{invoice.InvoiceNumber}.pdf", FileFormat.Pdf.ContentType), cancellationToken);
    await db.SaveChangesAsync(cancellationToken);
    logger.LogInformation("Confirmation email for order {OrderId}: {State}", orderId, queued ? "queued" : "already queued");
}

Kural basit: zincirdeki her adımın kendi idempotency anahtarı var ve bu anahtar adımın yazdığı şeyin kendisi. Faturanın anahtarı dosya yolu, e-postanın anahtarı outbox satırı, SMTP (e-posta gönderme protokolü) tarafının anahtarı da üçüncü yazıda bu anahtardan türettiğimiz Message-Id. Retry, background job'larda varsayılan durum. Background Job'lar serisinin retry ve idempotency yazısındaki "at-least-once" (en az bir kez çalıştırma garantisi) gerçeği burada da geçerli. Bu sınıflar Bookstore.Notifications içinde ve Hangfire'ı tanımıyor. Zinciri kuran API.

Hangfire ile Zincir: Enqueue ve ContinueJobWith

Birinci yazıda raporu bellekteki bir Channel ile kuyruğa almıştık ve uygulama yeniden başlayınca kuyruktaki iş kayboluyordu. Kalıcı hâli için Hangfire kullanıyoruz. Bookstore'un asıl veritabanı SQL Server ve orada Hangfire.SqlServer kullanılır. Bu seri SQLite ile çalıştığı için job'lar Hangfire.Storage.SQLite ile ayrı bir dosyada duruyor:

// Kalıcı job'lar: Hangfire onları kendi SQLite dosyasında tutar (production'da BookstoreDb'nin yanında Hangfire.SqlServer)
builder.Services.AddHangfire(config => config
    .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
    .UseSimpleAssemblyNameTypeSerializer()
    .UseRecommendedSerializerSettings()
    .UseSQLiteStorage(builder.Configuration.GetConnectionString("Jobs"), new SQLiteStorageOptions { QueuePollInterval = TimeSpan.FromSeconds(1) })
    .UseFilter(new AutomaticRetryAttribute { Attempts = 5, DelaysInSeconds = [5, 30, 120, 600, 3600] }));
builder.Services.AddHangfireServer(options => options.WorkerCount = 4);

Zincir iki satır. ContinueJobWith ile kurulan ikinci job, ilki Succeeded olana kadar Awaiting durumunda bekliyor:

// Ordering outbox'ındaki OrderPlaced'i okuyan relay çağırır; aynı sipariş için iki kez çağrılabilir
app.MapPost("/api/orders/{orderId:int}/placed", (int orderId, IBackgroundJobClient jobs) =>
{
    string invoiceJob = jobs.Enqueue<OrderDocumentJobs<BookstoreContext>>(j => j.GenerateInvoiceAsync(orderId, CancellationToken.None));
    // Yalnız fatura job'ı başarılı olunca çalışır; başarısız fatura job'ı kendi başına tekrar denenir
    string emailJob = jobs.ContinueJobWith<OrderDocumentJobs<BookstoreContext>>(invoiceJob,
        j => j.QueueConfirmationEmailAsync(orderId, CancellationToken.None));
    return Results.Accepted($"/api/jobs/{emailJob}", new { invoiceJob, emailJob });
});

CancellationToken.None yalnız bir yer tutucu; Hangfire job'ı çalıştırırken yerine kendi token'ını koyuyor. Bookstore'da bu endpoint'i Ordering module'ünün outbox relay'i çağırıyor. OrderPlaced siparişle aynı transaction'da yazılıyor ve relay onu Hangfire'a aktarıyor. Relay çökerse aynı olayı iki kez aktarabilir; mekaniği Messaging ve Event-Driven Architecture serisinde. İlk deneme:

$ curl -s -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:52560/api/orders/1042/placed
{"invoiceJob":"1","emailJob":"2"}
HTTP 202
$ curl -s http://127.0.0.1:52560/api/jobs/1
{"jobId":"1","state":"Succeeded","job":"OrderDocumentJobs.GenerateInvoiceAsync"}
$ curl -s http://127.0.0.1:52560/api/jobs/2
{"jobId":"2","state":"Succeeded","job":"OrderDocumentJobs.QueueConfirmationEmailAsync"}
$ curl -s http://127.0.0.1:52560/api/outbox
[{"idempotencyKey":"order-confirmed:1042","status":"Sent","attempts":1,"nextAttemptAtUtc":"2026-11-15T07:12:24.127751","sentAtUtc":"2026-11-15T07:12:26.077986"}]

Lisans notu: Hangfire'ın NuGet paketindeki LICENSE.md, yazılımın LGPL (kütüphane olarak kullanan uygulamaya kendi lisansını dayatmayan copyleft lisans) 3. sürümü ya da ücretli ticari lisansla kullanılabildiğini söylüyor. Ticari lisans, değiştirilmiş kopyaları kapalı dağıtmak ve Pro paketleri için gerekiyor. Bookstore kütüphaneyi değiştirmeden NuGet paketi olarak kullanıyor. Birinci yazıdaki kontrol bu kez de kırmızı yandı: lisans bir dosya olarak geldiği için araç tipini belirleyemedi. Üç Hangfire paketi için sürüme bağlı LGPL-3.0-only override'ı ekledik ve bu lisansı izin listesine aldık. Kararın bu bölümü de sürüm yükseltildiğinde yeniden gözden geçirilmeli.

Bir Adım Başarısız Olunca

Azurite container'ını durdurup 1043 numaralı siparişi verdik. İlk denemede Hangfire hiç devreye girmedi: Azure SDK'nın kendi retry'ları yüzünden fatura job'ı 25 saniye Processing durumunda kaldı ve Azurite geri gelince aynı denemede bitti. İki katmanlı retry'ın bedeli bu. SDK uzun bekledikçe Hangfire'ın worker thread'i o süre boyunca meşgul kalıyor. SDK'nın retry'ını kısalttık ve uzun kesintilerin kararını job'a bıraktık:

// SDK kendi başına da retry yapar; kısa tut ki uzun kesintilere job'ın kendi retry'ı (Hangfire) karar versin
BlobClientOptions blobOptions = new()
{
    Retry = { MaxRetries = 2, Delay = TimeSpan.FromSeconds(1), NetworkTimeout = TimeSpan.FromSeconds(10) },
};

Aynı deney 1044 numaralı siparişle tekrarlandı. 30 saniye sonra durum:

{"jobId":"5","state":"Scheduled","job":"OrderDocumentJobs.GenerateInvoiceAsync","reason":"Retry attempt 2 of 5: Retry failed after 3 tries. Retry settings can be…"}
{"jobId":"6","state":"Awaiting","job":"OrderDocumentJobs.QueueConfirmationEmailAsync"}

Log'da:

warn: Hangfire.AutomaticRetryAttribute[0]
      Failed to process the job '5': an exception occurred. Retry attempt 1 of 5 will be performed in 00:00:05.
      System.AggregateException: Retry failed after 3 tries. Retry settings can be adjusted in ClientOptions.Retry or by configuring a custom retry policy in ClientOptions.RetryPolicy. (Connection refused (127.0.0.1:52581)) ...
warn: Hangfire.AutomaticRetryAttribute[0]
      Failed to process the job '5': an exception occurred. Retry attempt 2 of 5 will be performed in 00:00:30.

Azurite'ı yeniden başlattıktan sonra iki job da Succeeded oldu. Hangfire'ın kendi tablosundaki durum geçmişi zinciri adım adım gösteriyor:

$ sqlite3 bookstore-jobs.db "select JobId, Name, substr(Reason, 1, 100) from State where JobId in (5, 6) order by Id"
5|Enqueued|
6|Awaiting|
5|Processing|
5|Failed|An exception occurred during performance of the job.
5|Scheduled|Retry attempt 1 of 5: Retry failed after 3 tries. Retry settings can be…
5|Enqueued|Triggered by DelayedJobScheduler
5|Processing|
5|Failed|An exception occurred during performance of the job.
5|Scheduled|Retry attempt 2 of 5: Retry failed after 3 tries. Retry settings can be…
5|Enqueued|Triggered by DelayedJobScheduler
5|Processing|
6|Enqueued|
5|Succeeded|
6|Processing|
6|Succeeded|

Job 6 hiç başarısız olmadı, çünkü hiç erken başlamadı. Zincirin başarısız bir adımda baştan başlaması gerekmiyor; başarısız olan adım kendi yerinde tekrar deneniyor.

Aynı Sipariş İki Kez

1043'ü bir kez daha verdik. Hangfire iki yeni job açtı ve ikisi de başarılı oldu, ama iş yapmadılar:

info: Bookstore.Notifications.Jobs.OrderDocumentJobs[0]
      Invoice for order 1043: invoices/2026/BKS-2026-001043.pdf (already stored)
info: Bookstore.Notifications.Jobs.OrderDocumentJobs[0]
      Confirmation email for order 1043: already queued

Mailpit'te her sipariş için tek e-posta var ve her birinde bir ek:

Monthly sales report 2026-10 is ready | reports@example.com | attachments 0
Your Bookstore order #1044 is confirmed | ada@example.com | attachments 1
Your Bookstore order #1043 is confirmed | ada@example.com | attachments 1
Your Bookstore order #1042 is confirmed | ada@example.com | attachments 1

İki kopya aynı anda çalışırsa ikisi de dosyayı yok görüp render edebilir. Fatura deterministik olduğu için ikinci yazma aynı dosyanın üzerine yazar. E-posta tarafında outbox'taki IdempotencyKey sütunu unique index'li olduğu için ikinci kayıt hata alır, Hangfire adımı tekrar dener ve ikinci denemede "already queued" yazılır.

Ekler Outbox'ta Değil, Storage'da

Üçüncü yazıda ekleri outbox satırına gömmemeyi önermiştik. Outbox satırı yalnız dosyanın yolunu tutuyor; dispatcher e-postayı gönderirken eki IFileStore'dan okuyor:

private async Task<EmailMessage> ToMessageAsync(OutboxEmail email, CancellationToken cancellationToken)
{
    EmailMessage message = email.ToMessage();
    if (email.AttachmentPath is null)
    {
        return message;
    }

    using MemoryStream content = new();
    await using (Stream stored = await files.OpenReadAsync(email.AttachmentPath, cancellationToken))
    {
        await stored.CopyToAsync(content, cancellationToken);
    }

    return message with { Attachments = [new EmailAttachment(email.AttachmentName!, email.AttachmentContentType!, content.ToArray())] };
}

Dosya yoksa bu kalıcı bir hata: tekrar denemek dosyayı geri getirmez. Dispatcher bu durumda e-postayı ilk denemede Failed yapıyor ve bunun bir unit test'i var. E-postadaki link storage URL'i değil, uygulamanın https://bookstore.example.com/orders/1042/invoice adresi. Kullanıcı tıkladığında uygulama sahipliği kontrol ediyor ve sekizinci yazıdaki 5 dakikalık URL'i üretiyor. E-postaya konan bir SAS (Shared Access Signature; süresi ve yetkisi sınırlı erişim imzası), e-posta iletildiği anda başkasının da elinde olurdu.

Aylık Rapor: Recurring Job

Rapor job'ı da aynı kuralla yazıldı. Dosya yolu reports/monthly-sales-2026-10.xlsx, outbox anahtarı monthly-sales:2026-10. Zamanlama için Hangfire'ın recurring job'ını kullanıyoruz:

// Her ayın 1'inde İstanbul saatiyle 06:00; AddOrUpdate idempotent olduğu için her açılışta çalışabilir
app.Services.GetRequiredService<IRecurringJobManager>().AddOrUpdate<MonthlySalesReportJob<BookstoreContext>>(
    JobEndpoints.MonthlyReportId, j => j.RunAsync(null, CancellationToken.None), "0 6 1 * *",
    new RecurringJobOptions { TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul") });

Cron ifadesi ve time zone, Hangfire'ın tablosunda saklanıyor. Bir sonraki çalışma zamanı 1796094000000, yani 1 Aralık 2026 UTC (eşgüdümlü evrensel saat) ile 03:00, İstanbul saatiyle 06:00. Time zone'u vermeseydik ifade UTC olarak yorumlanır ve rapor üç saat erken çalışırdı; DST (yaz saati uygulaması) tuzakları Background Job'lar serisinin cron yazısında anlatılıyor. Job'ı beklemeden tetikledik ve Monthly sales report 2026-10: reports/monthly-sales-2026-10.xlsx, email queued satırını aldık. month parametresi null olduğu için job önceki ayı İstanbul saatine göre hesapladı.

Kalıcılığı da denedik. Eylül raporunu kuyruğa koyup API'yi hemen kapattık:

$ curl -s -X POST -H 'Content-Type: application/json' -d '{"month":"2026-09"}' http://127.0.0.1:52560/api/reports/monthly-sales
{"jobId":"10"}
$ sqlite3 bookstore-jobs.db "select Id, StateName from Job where Id = 10"
10|Enqueued

API yeniden açıldıktan birkaç saniye sonra /api/jobs/10 durumu Succeeded gösterdi ve log'da Monthly sales report 2026-09: reports/monthly-sales-2026-09.xlsx, email queued satırı vardı. Birinci yazıdaki Channel aynı durumda işi kaybediyordu.

Integration Test: Mailpit, Azurite ve PdfPig

Hangfire yalnız zamanlayıcı. Asıl doğrulamak istediğimiz şey, adımların gerçek altyapıyla doğru çalışması ve tekrar edilebilir olması. Test adımları Hangfire olmadan, her birini iki kez çağırıyor. Fatura Azurite'a, e-posta STARTTLS (bağlantıyı sonradan şifreli hâle yükselten SMTP komutu) ve parolalı Mailpit'e gidiyor ve testin sonunda Mailpit'in API'sinden ek indirilip PdfPig ile okunuyor:

for (int run = 0; run < 2; run++)
{
    await jobs.GenerateInvoiceAsync(orderId, ct);
    await jobs.QueueConfirmationEmailAsync(orderId, ct);
}

await using (MailKitEmailSender sender = new(Options.Create(MailpitSmtp()), NullLogger<MailKitEmailSender>.Instance))
{
    OutboxEmailDispatcher<BookstoreContext> dispatcher = new(services.GetRequiredService<IServiceScopeFactory>(), sender, files,
        TimeProvider.System, outboxOptions, NullLogger<OutboxEmailDispatcher<BookstoreContext>>.Instance);
    Assert.Equal(1, await dispatcher.DispatchDueAsync(ct));
    Assert.Equal(0, await dispatcher.DispatchDueAsync(ct));
}

...
// Ek, saklanan PDF'in kendisi: metninde sipariş numarası, Türkçe adres ve toplam var
byte[] pdfBytes = await mailpit.GetByteArrayAsync($"/api/v1/message/{summary.ID}/part/{attachment.PartID}", ct);
using PdfDocument pdf = PdfDocument.Open(pdfBytes);
string text = string.Join(" ", pdf.GetPage(1).GetWords().Select(w => w.Text));
Assert.Contains($"Order #{orderId}", text);
Assert.Contains("34710 İstanbul, Türkiye", text);
Assert.Contains("Grand total ₺1.702,00", text);

Sipariş numarası her koşuda rastgele; böylece önceki koşuların e-postaları ve dosyaları sonucu etkilemiyor. Test, ortam değişkenleri yoksa kendini atlıyor; CI'da iki container'ı service olarak başlatıp değişkenleri vermek yeterli:

$ export MAILPIT_API_URL=http://127.0.0.1:52562 MAILPIT_SMTP_PORT=52561
$ export Notifications__Smtp__Password=<password> Notifications__Smtp__TrustedCertificateSha256=<sha256>
$ export AZURITE_CONNECTION_STRING=<connection string>
$ dotnet test --project tests/Bookstore.Notifications.Tests -- --filter-class "*OrderDocumentsChainTests" --filter-class "*MailpitEmailTests"
  total: 2
  failed: 0
  succeeded: 2
  duration: 898ms

Yazının sonunda bütün test projesi 31 testten 30'unu geçti. Atlanan tek test, S3 (Amazon'un object storage servisi) uyumlu bir sunucu gerektiren ve sekizinci yazıda RustFS'e karşı geçen test.

Üretimde Dikkat

Hangfire storage'ını uygulamanın veritabanıyla aynı sunucuda tut, ama ayrı bir şemada. Bookstore'da bu BookstoreDb üzerinde Hangfire.SqlServer demek.

Dashboard'u koru. MapHangfireDashboard varsayılan olarak yalnız aynı makineden gelen isteklere açık; production'da bir authorization filter ile yalnız back office rollerine aç. Job'ları izlemek ve alarm kurmak Background Job'lar serisinin dashboard ve monitoring yazısının konusu.

CPU yiyen işleri ayır. Kapak thumbnail'ları da artık bir Hangfire job'ı. Belge ve görsel işlerini ayrı bir queue'ya ve gerekirse ayrı bir worker sürecine almak, sipariş e-postalarının bir görsel yükünün arkasında beklemesini önler.

Retry katmanlarını bil. SDK, HTTP istemcisi, Hangfire ve outbox dispatcher'ın her birinin kendi retry'ı var. İç katmanları kısa tut; uzun kesintiye dış katman karar versin.

Olayı kaybetme. OrderPlaced'in sipariş ile aynı transaction'da yazılması ve bir relay ile Hangfire'a aktarılması zincirin başlangıç noktasını da güvenilir yapıyor. Adımlar idempotent olduğu için relay'in aynı olayı iki kez aktarması sorun değil.

Sık Düşülen Tuzaklar

Zincirdeki bir adım başarısız olunca baştan başlamak. Fatura numarası ikinci kez alınır ya da müşteri iki farklı PDF görür. Her adım kendi yerinde tekrar denenmeli.

Idempotency'yi Hangfire'a bırakmak. Hangfire bir job'ı en az bir kez çalıştırır; iki kez çalıştırmamayı garanti etmez.

E-postaya uzun ömürlü storage linki koymak. Link uygulamaya gitsin, uygulama kısa ömürlü URL'i o an üretsin.

Eki outbox satırına gömmek. Veritabanı şişer ve aynı PDF iki yerde durur.

Recurring job'ı time zone vermeden tanımlamak.

Küçük Bir Deneme

★☆☆ AutomaticRetryAttribute'un Attempts değerini 1 yap, Azurite kapalıyken bir sipariş ver ve job'ın son durumunu /api/jobs/{id} ile incele. Continuation'a ne oluyor?

★★☆ OrderDocumentJobs'a üçüncü bir adım ekle: fatura e-postası gönderildikten sonra siparişi "documents ready" olarak işaretleyen bir job. Bu adımın idempotency anahtarı ne olmalı?

★★★ OrderPlaced için gerçek bir outbox relay yaz: olay satırını okuyan, Hangfire'a aktaran ve satırı işaretleyen bir BackgroundService. Relay, Hangfire'a aktardıktan sonra ama satırı işaretlemeden çökerse ne olur ve neden sorun değildir?

Seri burada bitiyor. Bookstore artık e-postayı MailKit ve Razor şablonlarıyla, Excel'i ClosedXML ile, PDF'i QuestPDF ve Chromium ile üretiyor, kapakları SkiaSharp ile işliyor, dosyaları local diskte, Azure Blob Storage'da ya da S3'te tutuyor ve hepsini tekrar edilebilir job'larla birbirine bağlıyor. Her kütüphanenin lisansı CI'daki bir kontrolle izleniyor. Bu yolda gördüğümüz lisans değişiklikleri, PDFsharp'ın AGPL'e (ağ üzerinden sunulan yazılımda da kaynak kodu paylaşmayı isteyen lisans) geçmesi ve ImageSharp 4'ün build anahtarı, kontrolün neden her restore'da çalışması gerektiğini gösterdi. Konuyu derinleştirmek istersen job'ların işletme tarafı için Background Job'lar serisine, olay ve outbox mekaniği için Messaging ve Event-Driven Architecture serisine, storage kavramları için de Cloud Temelleri serisine dönebilirsin.