Hangfire éles környezetben: retry, idempotencia és párhuzamos futás | MagyarFejlesztők.hu
Backend

Hangfire éles környezetben: retry, idempotencia és párhuzamos futás

A Hangfire-rel néhány sorból tartós háttérfeladatot készíthetünk: a metódushívás bekerül egy perzisztens tárolóba, egy vagy több worker később végrehajtja, a Dashboardon pedig követhető az állapota. Ettől azonban a háttérmunka még nem lesz automatikusan üzembiztos.

Éles környezetben a nehezebb kérdések csak ezután következnek:

- Mi történik, ha a folyamat a feladat közepén leáll?
- Biztonságosan lefuthat-e ugyanaz a művelet többször?
- Mely hibák után érdemes újrapróbálkozni?
- Hogyan akadályozzuk meg, hogy a workerek túlterheljék az adatbázist vagy egy külső API-t?
- Mi történik telepítés, alkalmazás-újraindítás vagy szerverhiba közben?

> **A legfontosabb tervezési szabály:** Hangfire-feladatot mindig úgy készíts, mintha ugyanaz a munka egynél többször is végrehajtódhatna.

A példák a Hangfire 1.8.x API-jára és ASP.NET Core környezetre épülnek. Meglévő, régebbi Hangfire-telepítés frissítésekor a kompatibilitási szint és az adatbázisséma módosítása előtt mindig ellenőrizni kell a hivatalos upgrade guide-ot.

## Mit garantál a Hangfire, és mit nem?

A Hangfire a háttérfeladat leírását tartós tárolóban őrzi. A tárolóba többek között bekerül:

- a meghívandó típus és metódus;
- a metódus paramétereinek szerializált értéke;
- a queue neve;
- a feladat állapota;
- a retry-k és állapotváltások adatai.

Egy worker kiveszi a feladatot a queue-ból, meghívja a metódust, majd siker esetén Succeeded, hiba esetén pedig jellemzően Scheduled vagy Failed állapotba mozgatja.

A végrehajtás közben azonban több bizonytalan helyzet is előállhat:

1. A feladat elvégzi az üzleti műveletet.
2. A folyamat leáll, mielőtt a Hangfire rögzítené a sikeres befejezést.
3. A feladat később újra queue-ba kerül.
4. Ugyanaz az üzleti művelet ismét lefut.

Ez nem feltétlenül Hangfire-hiba. Elosztott vagy többfolyamatos háttérfeldolgozásnál nem lehet minden esetben biztosan eldönteni, hogy a munka már megtörtént-e, ha a végrehajtó és az állapottároló közötti kommunikáció megszakad.

A Hangfire saját dokumentációja ezért az automatikus retry kikapcsolása mellett is arra figyelmeztet, hogy a feladat a leállítási és kompenzációs mechanizmusok miatt többször végrehajtódhat. A helyes szemlélet a **legalább egyszeri feldolgozás**, amelyhez idempotens üzleti logika társul.

## Alapkonfiguráció ASP.NET Core és SQL Server használatával

A minimálisan szükséges csomagok:

dotnet add package Hangfire.AspNetCore
dotnet add package Hangfire.SqlServer
dotnet add package Microsoft.Data.SqlClient


Egy új ASP.NET Core alkalmazás alapkonfigurációja például így nézhet ki:

using Hangfire;
using Hangfire.Dashboard;
using Hangfire.SqlServer;

var builder = WebApplication.CreateBuilder(args);

// Az alkalmazás tényleges hitelesítési sémáját külön kell konfigurálni.
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();

string hangfireConnection = builder.Configuration
    .GetConnectionString("Hangfire")
    ?? throw new InvalidOperationException(
        "A Hangfire connection string nincs beállítva.");

builder.Services.AddHangfire(configuration => configuration
    .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
    .UseSimpleAssemblyNameTypeSerializer()
    .UseRecommendedSerializerSettings()
    .UseSqlServerStorage(hangfireConnection));

builder.Services.AddHangfireServer(options =>
{
    // Példaérték. Éles környezetben mérés alapján kell beállítani.
    options.WorkerCount = 8;

    options.Queues = new[]
    {
        "a-critical",
        "default",
        "z-bulk"
    };

    options.ShutdownTimeout = TimeSpan.FromSeconds(30);
});

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapHangfireDashboard("/hangfire", new DashboardOptions
{
    Authorization = new IDashboardAuthorizationFilter[]
    {
        new AdministratorDashboardAuthorizationFilter()
    }
});

app.Run();

public sealed class AdministratorDashboardAuthorizationFilter
    : IDashboardAuthorizationFilter
{
    public bool Authorize(DashboardContext context)
    {
        HttpContext httpContext = context.GetHttpContext();

        return httpContext.User.Identity?.IsAuthenticated == true
            && httpContext.User.IsInRole("Administrator");
    }
}


A WorkerCount = 8 itt nem általános ajánlás, hanem szemléltető érték. A Hangfire alapértelmezett worker-száma a processzormagok számának ötszöröse, de ezt nem érdemes automatikusan optimálisnak tekinteni. CPU-igényes feladatoknál már jóval alacsonyabb konkurencia is teljes terhelést okozhat, I/O-korlátos feladatoknál pedig több worker is indokolt lehet.

### Meglévő rendszer frissítése

A CompatibilityLevel.Version_180 új telepítésnél megfelelő kiindulópont. Meglévő 1.7-es vagy régebbi rendszerben csak akkor szabad átállítani, amikor:

- minden Hangfire Server példány kompatibilis verzióra frissült;
- az adatbázisséma migrációja megtörtént;
- rolling deployment esetén már nem fut régi worker ugyanazon a tárolón.

A Hangfire SQL Server tárolója 1.8-tól képes a sémaverzió alapján több ajánlott opciót automatikusan megválasztani. Erősen terhelt vagy szigorú adatbázis-jogosultságokat használó rendszernél ugyanakkor a sémamigrációt célszerű kontrollált telepítési lépésként kezelni, nem az alkalmazás első indulására hagyni.

## A job legyen egyszerű szolgáltatás, ne statikus kódrészlet

A háttérfeladat üzleti logikáját érdemes normál, dependency injectionnel létrehozott osztályba helyezni:

public sealed class OrderExportJob
{
    private readonly AppDbContext _dbContext;
    private readonly IOrderExporter _exporter;
    private readonly ILogger<OrderExportJob> _logger;

    public OrderExportJob(
        AppDbContext dbContext,
        IOrderExporter exporter,
        ILogger<OrderExportJob> logger)
    {
        _dbContext = dbContext;
        _exporter = exporter;
        _logger = logger;
    }

    [Queue("z-bulk")]
    [AutomaticRetry(
        Attempts = 5,
        DelaysInSeconds = new[] { 30, 120, 600, 1800, 3600 })]
    public async Task RunAsync(
        long orderId,
        CancellationToken cancellationToken)
    {
        Order order = await _dbContext.Orders
            .AsNoTracking()
            .SingleAsync(
                item => item.Id == orderId,
                cancellationToken);

        await _exporter.ExportAsync(order, cancellationToken);

        _logger.LogInformation(
            "A(z) {OrderId} rendelés exportálása befejeződött.",
            orderId);
    }
}


A job feladása:

string jobId = backgroundJobClient.Enqueue<OrderExportJob>(
    job => job.RunAsync(
        orderId,
        CancellationToken.None));


A CancellationToken.None itt csak az expression létrehozásához szükséges. A Hangfire végrehajtáskor a tokent saját, érvényes példányra cseréli.

Az instance metódus és a konstruktor-injektált függőségek előnyei:

- a job közvetlenül unit tesztelhető;
- nem kell service locator vagy statikus globális állapot;
- az EF Core DbContext és más scoped szolgáltatások jobonként külön scope-ban jöhetnek létre;
- az üzleti logika nincs összekeverve a queue-kezeléssel.

## Csak kis és stabil paramétereket adj át

A Hangfire nem magát az objektumot tartja memóriában, hanem a metódushívást és a paramétereket szerializálja. Ezért kerülendő:

backgroundJobClient.Enqueue<OrderExportJob>(
    job => job.RunWithFullOrderAsync(
        order,
        CancellationToken.None));


Helyette az üzleti rekord stabil azonosítóját add át:

backgroundJobClient.Enqueue<OrderExportJob>(
    job => job.RunAsync(
        order.Id,
        CancellationToken.None));


Ennek több oka van:

- kisebb lesz a Hangfire-tárolóban lévő payload;
- a job a végrehajtáskor az aktuális adatot olvassa be;
- csökken a szerializációs kompatibilitási probléma;
- kevesebb személyes vagy érzékeny adat jelenik meg a Dashboardon;
- könnyebb ugyanazt a jobot később újrapróbálni.

A paraméterekbe ne kerüljön jelszó, API-kulcs, access token vagy más titok. A Dashboard a job metódusnevét és szerializált argumentumait is megmutathatja az arra jogosult felhasználóknak.

## Retry: nem minden hibát kell ugyanúgy kezelni

A Hangfire alapértelmezés szerint tízszer próbálja újra a hibával befejeződő jobokat, növekvő késleltetéssel. Ez jó biztonsági háló átmeneti hibákra, de nem helyettesíti a tudatos hibakezelést.

| Hibatípus | Példa | Célszerű kezelés |
|---|---|---|
| Átmeneti | HTTP 503, rövid adatbázis-kimaradás | Korlátozott retry késleltetéssel |
| Tartós konfigurációs | Hibás API-kulcs, hiányzó beállítás | Gyors hibára futás és riasztás |
| Üzleti végállapot | Törölt rendelés, már feldolgozott rekord | Naplózott, sikeres befejezés vagy külön státusz |
| Programhiba | NullReferenceException, hibás mapping | Sikertelen job, javítás és újratelepítés |

### Jobonkénti retry-beállítás

[AutomaticRetry(
    Attempts = 5,
    DelaysInSeconds = new[] { 30, 120, 600, 1800, 3600 },
    OnAttemptsExceeded = AttemptsExceededAction.Fail)]
public async Task SynchronizeAsync(
    long productId,
    CancellationToken cancellationToken)
{
    await _synchronizer.SynchronizeAsync(
        productId,
        cancellationToken);
}


A Failed állapot általában jobb, mint a sikertelen job automatikus törlése. A törölt job könnyebben eltűnik az üzemeltető látóteréből, míg a Failed állapot riasztható és manuálisan újrapróbálható.

### Retry teljes kikapcsolása

[AutomaticRetry(Attempts = 0)]
public Task RunOnceAsync(CancellationToken cancellationToken)
{
    return _service.ExecuteAsync(cancellationToken);
}


Ez csak az AutomaticRetryAttribute által kezdeményezett retry-kat tiltja le. Nem garantálja, hogy a job pontosan egyszer fut le: leállítás, kapcsolatvesztés vagy más kompenzációs mechanizmus után továbbra is sor kerülhet ismételt végrehajtásra.

### Ne nyeld el az ismeretlen kivételt

Kerülendő:

try
{
    await _client.SendAsync(cancellationToken);
}
catch (Exception exception)
{
    _logger.LogError(exception, "A küldés sikertelen.");
}


A job ebben az esetben sikeresnek látszik, noha a művelet nem történt meg. A helyes minta:

try
{
    await _client.SendAsync(cancellationToken);
}
catch (KnownBusinessException exception)
{
    _logger.LogWarning(
        exception,
        "A művelet üzleti okból nem hajtható végre.");

    await _statusStore.MarkRejectedAsync(
        exception.Reason,
        cancellationToken);
}
catch (Exception exception)
{
    _logger.LogError(exception, "A küldés sikertelen.");
    throw;
}


Az ismeretlen vagy átmeneti hibát tovább kell dobni, hogy a Hangfire hibásnak lássa a végrehajtást, és alkalmazhassa a beállított retry-szabályt.

## Idempotencia: ugyanaz a job kétszer is ugyanarra az eredményre jusson

Egy művelet idempotens, ha azonos bemenettel többször végrehajtva nem okoz további nem kívánt mellékhatást.

### Természetesen idempotens művelet

order.Status = OrderStatus.Exported;


Ha a státusz már Exported, az ismételt értékadás nem változtatja meg újra az üzleti eredményt.

### Nem idempotens művelet

customer.Balance += amount;


Ha ugyanaz a job kétszer fut le, az összeg kétszer kerül jóváírásra.

Ugyanez a kockázat fennáll például:

- e-mail vagy SMS ismételt kiküldésekor;
- számla vagy rendelés kétszeri létrehozásakor;
- készlet kétszeri csökkentésekor;
- külső fizetési művelet ismételt indításakor;
- webhook többszöri feldolgozásakor.

## Az egyszerű „már lefutott?” ellenőrzés önmagában kevés

Ez a kód első látásra megfelelőnek tűnhet:

if (await _dbContext.Invoices.AnyAsync(
        invoice => invoice.OrderId == orderId,
        cancellationToken))
{
    return;
}

_dbContext.Invoices.Add(CreateInvoice(orderId));
await _dbContext.SaveChangesAsync(cancellationToken);


Két worker azonban egyszerre is lefuthat:

1. mindkettő ellenőrzi, hogy nincs még számla;
2. mindkettő false eredményt kap;
3. mindkettő megpróbál új rekordot beszúrni.

A helyességet ezért nem kizárólag alkalmazáskóddal, hanem adatbázis-korláttal is védeni kell.

### Egyedi kulcs az üzleti művelethez

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Invoice>()
        .HasIndex(invoice => invoice.OrderId)
        .IsUnique()
        .HasDatabaseName("UX_Invoices_OrderId");
}


A job előzetesen ellenőrizhet, de a végső védelmet az egyedi index adja:

public sealed class InvoiceJob
{
    private readonly AppDbContext _dbContext;
    private readonly SqlServerUniqueConstraintDetector _constraintDetector;
    private readonly ILogger<InvoiceJob> _logger;

    public InvoiceJob(
        AppDbContext dbContext,
        SqlServerUniqueConstraintDetector constraintDetector,
        ILogger<InvoiceJob> logger)
    {
        _dbContext = dbContext;
        _constraintDetector = constraintDetector;
        _logger = logger;
    }

    [Queue("a-critical")]
    [AutomaticRetry(
        Attempts = 5,
        DelaysInSeconds = new[] { 30, 120, 600, 1800, 3600 })]
    public async Task CreateAsync(
        long orderId,
        CancellationToken cancellationToken)
    {
        bool alreadyExists = await _dbContext.Invoices
            .AsNoTracking()
            .AnyAsync(
                invoice => invoice.OrderId == orderId,
                cancellationToken);

        if (alreadyExists)
        {
            _logger.LogInformation(
                "A(z) {OrderId} rendeléshez már létezik számla.",
                orderId);

            return;
        }

        Order order = await _dbContext.Orders
            .SingleAsync(
                item => item.Id == orderId,
                cancellationToken);

        _dbContext.Invoices.Add(new Invoice
        {
            OrderId = order.Id,
            Number = $"ORDER-{order.Id:D10}",
            CreatedAtUtc = DateTime.UtcNow
        });

        try
        {
            await _dbContext.SaveChangesAsync(cancellationToken);
        }
        catch (DbUpdateException exception)
            when (_constraintDetector.IsViolation(
                exception,
                "UX_Invoices_OrderId"))
        {
            // Egy másik worker időközben már létrehozta ugyanazt a rekordot.
            _logger.LogInformation(
                "A(z) {OrderId} rendelés számlája párhuzamosan már létrejött.",
                orderId);
        }
    }
}


SQL Server esetén a duplikált kulcs tipikus hibaszámai a 2601 és a 2627:

using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;

public sealed class SqlServerUniqueConstraintDetector
{
    public bool IsViolation(
        DbUpdateException exception,
        string indexName)
    {
        return exception.GetBaseException() is SqlException sqlException
            && sqlException.Number is 2601 or 2627
            && sqlException.Message.Contains(
                indexName,
                StringComparison.OrdinalIgnoreCase);
    }
}


Ez a minta két fontos hibát is kezel:

- ha két worker egyszerre próbálja létrehozni ugyanazt a rekordot, csak az egyik jár sikerrel;
- ha a rekord már létrejött, de a folyamat a Hangfire sikerállapotának rögzítése előtt leállt, az újrafutás nem hoz létre második rekordot.

A példában szereplő számlalogika természetesen leegyszerűsített; valódi számlázásnál a jogi, sorszámozási és tranzakciós követelményeket külön kell kezelni.

## Külső mellékhatásoknál az adatbázis-flag nem ad pontosan egyszeri végrehajtást

Az alábbi megoldás továbbra is hibás lehet:

if (!notification.Sent)
{
    await _emailClient.SendAsync(notification, cancellationToken);

    notification.Sent = true;
    await _dbContext.SaveChangesAsync(cancellationToken);
}


Két kellemetlen hibapont van:

- ha előbb állítjuk Sent = true értékre, majd a küldés előtt leáll a folyamat, az üzenet elveszhet;
- ha előbb elküldjük az üzenetet, majd a mentés előtt áll le a folyamat, a retry újra elküldheti.

Egy helyi adatbázis és egy külső SMTP-, fizetési vagy HTTP-szolgáltatás között nincs automatikus közös ACID-tranzakció.

### Jobb megoldás: idempotency key

Ha a külső szolgáltatás támogat idempotenciakulcsot, minden retry során ugyanazt a stabil kulcsot kell küldeni:

string idempotencyKey = outboxMessage.Id.ToString("N");

await _paymentClient.CaptureAsync(
    request,
    idempotencyKey,
    cancellationToken);


A szolgáltató ekkor az azonos kulccsal ismételt kéréseket ugyanahhoz az üzleti művelethez tudja kötni.

### Jobb megoldás: transactional outbox

A transactional outbox akkor hasznos, amikor egy üzleti adatváltozást és egy később végrehajtandó külső műveletet megbízhatóan össze kell kapcsolni.

Az alapfolyamat:

1. Az üzleti rekord módosítása és az outbox-üzenet beszúrása ugyanabban a helyi adatbázis-tranzakcióban történik.
2. A tranzakció commitja után az outbox-rekord biztosan megmarad.
3. Egy Hangfire-job rendszeresen feldolgozza a még nem küldött rekordokat.
4. Sikeres külső művelet után az outbox-rekord ProcessedAtUtc mezőt kap.
5. Retry esetén ugyanaz az outbox-azonosító szolgál idempotenciakulcsként.

Egyszerű outbox-entitás:

public sealed class OutboxMessage
{
    public Guid Id { get; set; }
    public string Type { get; set; } = null!;
    public string Payload { get; set; } = null!;
    public DateTime CreatedAtUtc { get; set; }
    public DateTime? ProcessedAtUtc { get; set; }
    public int AttemptCount { get; set; }
    public string? LastError { get; set; }
}


Az üzleti módosítás és az outbox-bejegyzés közös tranzakcióban:

await using var transaction =
    await _dbContext.Database.BeginTransactionAsync(cancellationToken);

order.MarkPaid();

var message = new OutboxMessage
{
    Id = Guid.NewGuid(),
    Type = "order.paid",
    Payload = JsonSerializer.Serialize(new
    {
        OrderId = order.Id,
        order.CustomerId
    }),
    CreatedAtUtc = DateTime.UtcNow
};

_dbContext.OutboxMessages.Add(message);

await _dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);


Az outbox feldolgozóját recurring jobként is regisztrálhatjuk:

RecurringJob.AddOrUpdate<OutboxDispatcherJob>(
    "outbox:dispatch",
    "a-critical",
    job => job.DispatchBatchAsync(CancellationToken.None),
    "*/1 * * * *",
    new RecurringJobOptions
    {
        TimeZone = TimeZoneInfo.Utc
    });


Ez kiküszöböli azt a klasszikus dual-write hibát, amikor az üzleti mentés sikerül, de a Hangfire-job queue-ba helyezése előtt leáll az alkalmazás. Az outboxot feldolgozó recurring job később is megtalálja a még függő üzenetet.

Több dispatcher vagy worker esetén az outbox-rekordok lefoglalását is atomikusan kell megoldani. Egy egyszerű „lekérdezem a függő sorokat, majd feldolgozom őket” minta ugyanúgy race conditiont okozhat. Használható például feltételes státuszfrissítés, adatbázis-lock, lease-idő vagy más olyan claim-mechanizmus, amelynél egy rekordot egyszerre csak egy worker birtokolhat. A küldési művelet ettől függetlenül maradjon idempotens.

A külső szolgáltatás idempotenciatámogatása ettől még fontos. Ha a külső rendszer nem képes az ismételt kéréseket felismerni, a helyi alkalmazás önmagában nem mindig tudja kizárni a duplikált mellékhatást.

## Párhuzamos futás: a worker-szám üzleti döntés is

A worker-szám nem pusztán teljesítményparaméter. Meghatározza, hogy egyszerre hány job terhelheti:

- az alkalmazás adatbázisát;
- egy külső API-t;
- az SMTP-szervert;
- a fájlrendszert;
- a CPU-t és a memóriát;
- egy adott ügyfél vagy tenant erőforrásait.

### CPU- és I/O-korlátos feladatok

CPU-igényes munkánál, például képfeldolgozásnál vagy tömörítésnél, a processzormagokhoz közeli worker-szám is elegendő lehet.

I/O-korlátos munkánál, például HTTP-hívásoknál több worker is hasznos lehet, de a külső szolgáltatás rate limitje és a kapcsolatpool mérete korlátot szab.

A helyes értéket terheléses méréssel kell megállapítani. Figyelni kell legalább:

- a queue hosszát és a legrégebbi várakozó job korát;
- a végrehajtási idő mediánját és magas percentiliseit;
- az adatbázis CPU-, lock- és connection-pool terhelését;
- a külső szolgáltatások hibaarányát;
- a retry-k számát;
- a memóriahasználatot és a ThreadPool viselkedését.

## Queue-k segítségével különítsd el a terhelést

builder.Services.AddHangfireServer(options =>
{
    options.WorkerCount = 8;
    options.Queues = new[]
    {
        "a-critical",
        "default",
        "z-bulk"
    };
});


A jobot attribútummal irányíthatjuk queue-ba:

[Queue("a-critical")]
public Task SendPasswordResetAsync(
    long requestId,
    CancellationToken cancellationToken)
{
    return _sender.SendAsync(requestId, cancellationToken);
}


[Queue("z-bulk")]
public Task RebuildSearchIndexAsync(
    long productId,
    CancellationToken cancellationToken)
{
    return _indexer.RebuildAsync(productId, cancellationToken);
}


A queue nevében csak kisbetű, szám, aláhúzásjel és kötőjel használható.

Fontos, hogy a queue-k feldolgozási sorrendje storage-függő. Hangfire.SqlServer esetén az alfanumerikus sorrend számít, és a konfigurációs tömb sorrendje figyelmen kívül maradhat. Ezért használható például az a-critical és z-bulk elnevezés.

A queue-sorrend azonban nem teljes erőforrás-izoláció. Ha valóban biztosítani kell, hogy a tömeges munkák ne foglalják el a kritikus feladatok workereit, célszerű külön Hangfire Server példányokat vagy külön worker processzeket futtatni eltérő queue-listával és worker-számmal.

## A DisableConcurrentExecution nem üzleti zár

Gyakori megoldás:

[DisableConcurrentExecution(timeoutInSeconds: 60)]
public async Task RefreshCatalogAsync(
    CancellationToken cancellationToken)
{
    await _catalogService.RefreshAsync(cancellationToken);
}


Ez csökkentheti annak esélyét, hogy ugyanaz a metódus több jobpéldányban egyszerre fusson. A timeoutInSeconds a distributed lock megszerzésének várakozási ideje, nem a job maximális futási ideje.

A Hangfire dokumentációja ugyanakkor kifejezetten figyelmeztet arra, hogy a mechanizmus aktív storage-kapcsolatra támaszkodik. Kapcsolatvesztéskor a lock felszabadulhat úgy, hogy a futó job erről nem értesül.

Ezért a DisableConcurrentExecution:

- használható terheléscsökkentő vagy ütközésmérséklő eszközként;
- nem helyettesíti az adatbázis egyedi kulcsát;
- nem helyettesíti az optimista vagy pesszimista konkurenciakezelést;
- nem teszi szükségtelenné az idempotens üzleti logikát.

## Rate limit és throttling

A Hangfire Core-ban egyszerű, ingyenes megoldás lehet egy külön queue és korlátozott számú worker. Például egy külső API-t legfeljebb két párhuzamos jobbal terhelő dedikált worker:

builder.Services.AddHangfireServer(options =>
{
    options.WorkerCount = 2;
    options.Queues = new[] { "external-api" };
});


Ha több szerverpéldány hallgat ugyanarra a queue-ra, a teljes párhuzamosság a példányok workereinek összege lesz. Ezt skálázáskor külön figyelembe kell venni.

A Hangfire.Throttling mutexet, szemafort és időablakos rate limitert is kínál, de ez a Hangfire.Ace termékhez tartozó, privát feedről elérhető csomag. A hivatalos dokumentáció szerint ezek a korlátozók is best-effort módon működnek; adatkonzisztencia-védelemre továbbra sem szabad kizárólag rájuk hagyatkozni.

## Recurring job: az ütemezés nem akadályozza meg az átfedést

Egy napi feladat regisztrációja:

RecurringJob.AddOrUpdate<DailyReportJob>(
    "reports:daily-summary",
    "z-bulk",
    job => job.GenerateAsync(CancellationToken.None),
    "0 2 * * *",
    new RecurringJobOptions
    {
        TimeZone = TimeZoneInfo.Utc
    });


A recurring job azonosítója legyen stabil és egyedi. Az AddOrUpdate ugyanazzal az azonosítóval a meglévő definíciót frissíti.

A Hangfire recurring schedulere percenként ellenőrzi az esedékes definíciókat, majd normál fire-and-forget jobot helyez a queue-ba. Ebből következik:

- másodperc pontosságú ütemezésre nem ez a megfelelő eszköz;
- a Hangfire Servernek folyamatosan futnia kell;
- ha az előző futás tovább tart az ütemezési intervallumnál, a következő példány is queue-ba kerülhet;
- egy manuális trigger nem írja át automatikusan a következő tervezett futás idejét.

Hosszú recurring jobnál tehát ugyanúgy szükség van:

- idempotenciára;
- üzleti perióduskulcsra, például daily-report:2026-08-18;
- szükség esetén adatbázis-zárra vagy egyedi kulcsra;
- monitoringra, ha az előző futás még nem fejeződött be.

Időzónát célszerű explicit módon megadni. Belső feldolgozásnál az UTC a legkiszámíthatóbb; helyi üzleti időhöz rögzített jobnál a nyári és téli időszámítás hatását is tesztelni kell.

## CancellationToken és szabályos leállítás

A job fogadjon normál CancellationToken paramétert:

public async Task ImportAsync(
    long batchId,
    CancellationToken cancellationToken)
{
    IReadOnlyList<long> itemIds = await _repository
        .GetPendingItemIdsAsync(batchId, cancellationToken);

    foreach (long itemId in itemIds)
    {
        cancellationToken.ThrowIfCancellationRequested();

        await _importer.ImportAsync(
            itemId,
            cancellationToken);
    }
}


A tokent tovább kell adni minden olyan alsóbb szintű műveletnek, amely támogatja:

- EF Core lekérdezéseknek és mentéseknek;
- HttpClient hívásoknak;
- fájlműveleteknek;
- Task.Delay hívásoknak;
- hosszabb ciklusoknak.

Leállításkor a Hangfire megszakítási kérelmet küldhet a futó jobnak. Ha a job együttműködik, gyorsabban befejezhető a processz leállítása, és a félbeszakadt feladat később újra queue-ba kerülhet.

Az OperationCanceledException kivételt ne alakítsd át sikeres befejezéssé, ha a művelet valójában nem készült el:

try
{
    await _service.ExecuteAsync(cancellationToken);
}
catch (OperationCanceledException)
    when (cancellationToken.IsCancellationRequested)
{
    _logger.LogInformation(
        "A job szabályos leállítás miatt megszakadt.");

    throw;
}


## A webalkalmazás nem mindig ideális worker host

Hangfire Server futhat közvetlenül ASP.NET Core vagy klasszikus ASP.NET alkalmazásban, de az üzemeltetési környezetet figyelembe kell venni.

Webalkalmazásban problémát okozhat:

- IIS idle timeout vagy application pool recycle;
- platformoldali alvó állapot;
- automatikus skálázás miatti gyakori leállás;
- webes deployment közbeni processzcsere;
- a HTTP-kérések és a háttérjobok közös CPU- és memóriakerete.

Kritikus vagy időérzékeny háttérmunka esetén jobb lehet:

- külön Worker Service;
- Windows Service;
- külön konténer vagy deployment;
- folyamatosan futó processz, amely csak kijelölt queue-kat dolgoz fel.

Több Hangfire Server ugyanazt a tárolót használhatja, és együtt dolgozhatja fel a queue-kat. Ettől az idempotencia továbbra is kötelező, mert a több worker és a hibából történő újrafeldolgozás egyaránt növeli az ismételt vagy párhuzamos végrehajtás lehetőségét.

## Telepítéskor gondolj a már queue-ban lévő jobokra

A Hangfire a meghívandó típust, metódust és argumentumokat tartósan eltárolja. Egy deployment után ezért hibát okozhat, ha:

- átnevezed a job osztályát vagy namespace-ét;
- törlöd vagy átnevezed a metódust;
- inkompatibilisen módosítod a paraméterlistát;
- olyan assembly-verzió kerül ki, amely már nem tudja deszerializálni a régi argumentumot.

Biztonságosabb stratégia:

1. Új metódus vagy új jobverzió bevezetése.
2. Az új jobok már az új verziót használják.
3. A régi queue kiürülésének megvárása.
4. Csak ezután távolítod el a régi kontraktust.

Hosszú életű vagy több alkalmazásverzió által közösen használt tárolónál érdemes explicit verziózott job-interfészeket használni, például IProductSyncJobV1 és IProductSyncJobV2.

## Dashboard: üzemeltetési felület és biztonsági kockázat

A Hangfire Dashboard lehetőséget ad többek között:

- queue-k és szerverek megtekintésére;
- futó, sikeres, retry és failed jobok vizsgálatára;
- jobok manuális újrapróbálására;
- törlésre és recurring job manuális indítására;
- metódusnevek és argumentumok megtekintésére.

Ezért éles környezetben nem elég egy nehezen kitalálható URL. Kötelező a hitelesítés és a jogosultság-ellenőrzés. Szükség esetén az üzemeltetői szerepkör csak read-only hozzáférést kapjon.

A Dashboard hasznos, de nem teljes monitoringrendszer. Érdemes külön metrikát és riasztást kialakítani legalább az alábbiakra:

- tartósan növekvő queue-hossz;
- túl régi várakozó job;
- failed jobok száma;
- retry-ráta hirtelen emelkedése;
- eltűnt vagy nem elérhető Hangfire Server;
- recurring job elmaradt futása;
- szokatlanul hosszú végrehajtási idő;
- külső szolgáltatás hibaarányának növekedése.

A strukturált naplóban szerepeljen az üzleti azonosító:

_logger.LogInformation(
    "Termékszinkron indítása. ProductId: {ProductId}, Supplier: {Supplier}",
    productId,
    supplierCode);


A Hangfire job ID hasznos technikai korrelációs adat, de önmagában nem helyettesíti az üzleti művelet stabil kulcsát.

## Teszteld a duplikált és párhuzamos végrehajtást is

Egy job unit tesztje ne csak azt ellenőrizze, hogy első futásra működik. Fontos esetek:

- ugyanazzal az azonosítóval kétszer egymás után meghívva ugyanaz marad az eredmény;
- két párhuzamos hívás közül csak az egyik hoz létre üzleti rekordot;
- külső szolgáltatás átmeneti hibája után a következő futás folytatható;
- cancellation közben nem marad inkonzisztens állapot;
- már feldolgozott rekord esetén a job gyorsan és sikeresen befejeződik;
- hibás vagy törölt üzleti rekord nem okoz végtelen retry-t;
- az outbox ugyanazt az idempotenciakulcsot használja minden próbálkozáskor.

A jobosztály közvetlen meghívhatósága ezt jelentősen megkönnyíti:

await job.CreateAsync(orderId, CancellationToken.None);
await job.CreateAsync(orderId, CancellationToken.None);

int invoiceCount = await dbContext.Invoices
    .CountAsync(invoice => invoice.OrderId == orderId);

Assert.Equal(1, invoiceCount);


## Gyakori éles környezeti hibák

| Hiba | Következmény | Javítás |
|---|---|---|
| Teljes objektum átadása paraméterként | Nagy payload, régi adat, PII a Dashboardon | Csak stabil azonosító átadása |
| Attempts = 0 alapján pontosan egyszeri futás feltételezése | Duplikált mellékhatás | Idempotens üzleti logika |
| DisableConcurrentExecution kizárólagos védelemként | Race condition kapcsolatvesztéskor | Egyedi kulcs és tranzakció |
| Minden kivétel elnyelése | Hamis sikerállapot | Ismeretlen hiba továbbdobása |
| Korlátlan worker-szám | Adatbázis- vagy API-túlterhelés | Mérés, queue és worker-izoláció |
| Biztonság nélkül publikált Dashboard | Érzékeny adat és vezérlési lehetőség kiszivárgása | Hitelesítés és szerepkör |
| Webalkalmazás elalvása | Elmaradó recurring és delayed jobok | Always-on vagy külön worker |
| Külső küldés, majd helyi flag | Duplikáció vagy elveszett művelet | Outbox és idempotency key |
| Job metódus törlése deploymentkor | Régi queue-elemek deszerializációs hibája | Verziózott kontraktus és fokozatos kivezetés |

## Élesítési ellenőrzőlista

- [ ] A Hangfire tartós, megfelelően mentett és felügyelt storage-ot használ.
- [ ] A worker processz garantáltan fut, és nem alszik el inaktivitáskor.
- [ ] A Dashboard csak hitelesített és jogosult felhasználóknak érhető el.
- [ ] A job argumentumai kicsik, stabilak, és nem tartalmaznak titkot.
- [ ] Minden üzleti szempontból kritikus job idempotens.
- [ ] A helyességet szükség esetén adatbázis-egyedi kulcs vagy tranzakció védi.
- [ ] Külső mellékhatásokhoz idempotency key vagy outbox stratégia tartozik.
- [ ] A retry-k száma és késleltetése a konkrét hibamodellhez igazodik.
- [ ] A végleg hibás jobok Failed állapotba kerülnek és riasztást váltanak ki.
- [ ] A jobok fogadnak és továbbadnak CancellationToken értéket.
- [ ] A worker-számot terheléses mérés alapján állították be.
- [ ] A kritikus, normál és tömeges munkák queue-k vagy külön worker processzek szerint el vannak választva.
- [ ] A recurring jobok egyedi azonosítót és explicit időzónát használnak.
- [ ] A hosszú recurring jobok átfedése üzleti szinten kezelve van.
- [ ] A deployment kompatibilis a már queue-ban lévő jobokkal.
- [ ] Van riasztás failed jobra, queue-torlódásra és eltűnt workerre.
- [ ] A duplikált és párhuzamos végrehajtást integrációs teszt is lefedi.

## Összefoglalás

A Hangfire megbízható alapot ad tartós .NET háttérfeladatokhoz, de az üzleti helyességet nem tudja az alkalmazás helyett megtervezni.

A legfontosabb alapelvek:

- A job többszöri végrehajtását normál működési lehetőségként kezeld.
- A retry csak átmeneti hibákra megoldás; nem helyettesíti az idempotenciát.
- A DisableConcurrentExecution és a throttling terhelést mérsékel, de nem adatkonzisztencia-garancia.
- A kritikus invariánsokat adatbázis-korláttal és tranzakcióval védd.
- Külső mellékhatásokhoz használj stabil idempotenciakulcsot és szükség esetén transactional outboxot.
- A worker-számot, queue-kat és hostingmodellt az üzleti prioritások szerint alakítsd ki.
- A Dashboardot védd, a queue-kat pedig önálló metrikákkal és riasztásokkal felügyeld.

Egy jól megtervezett Hangfire-job nem azért biztonságos, mert soha nem ismétlődik meg, hanem azért, mert az ismételt, párhuzamos vagy félbeszakított végrehajtást is kiszámíthatóan kezeli.

## Források és további olvasnivaló

- [Hangfire – ASP.NET Core Applications](https://docs.hangfire.io/en/latest/getting-started/aspnet-core-applications.html)
- [Hangfire – Best Practices](https://docs.hangfire.io/en/latest/best-practices.html)
- [Hangfire – Dealing with Exceptions](https://docs.hangfire.io/en/latest/background-processing/dealing-with-exceptions.html)
- [Hangfire – Using Cancellation Tokens](https://docs.hangfire.io/en/latest/background-methods/using-cancellation-tokens.html)
- [Hangfire – Configuring the Degree of Parallelism](https://docs.hangfire.io/en/latest/background-processing/configuring-degree-of-parallelism.html)
- [Hangfire – Configuring Job Queues](https://docs.hangfire.io/en/latest/background-processing/configuring-queues.html)
- [Hangfire – Concurrency & Rate Limiting](https://docs.hangfire.io/en/latest/background-processing/throttling.html)
- [Hangfire – Performing Recurrent Tasks](https://docs.hangfire.io/en/latest/background-methods/performing-recurrent-tasks.html)
- [Hangfire – Using Dashboard UI](https://docs.hangfire.io/en/latest/configuration/using-dashboard.html)
- [Hangfire – Using SQL Server](https://docs.hangfire.io/en/latest/configuration/using-sql-server.html)
- [Hangfire – Making ASP.NET Application Always Running](https://docs.hangfire.io/en/latest/deployment-to-production/making-aspnet-app-always-running.html)
- [Hangfire – Upgrading to Hangfire 1.8](https://docs.hangfire.io/en/latest/upgrade-guides/upgrading-to-hangfire-1.8.html)
- [Microsoft – Idempotent Consumer pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/idempotent-consumer)
- [Microsoft – Outbox pattern és integrációs események](https://learn.microsoft.com/en-us/dotnet/architecture/microservices/multi-container-microservice-net-applications/subscribe-events)
← Vissza a cikkekhez


💬 Hozzászólások (0)

A hozzászóláshoz jelentkezz be!

Még nem érkezett hozzászólás. Légy te az első!