💻 Développement

dev-hangfire-job-scheduler

Planification et gestion de background jobs avec Hangfire en .NET. Patterns de retry, scheduling récurrent, queues, monitoring, idempotence et bonnes pratiques 2026.

⚡ Installation & lancement en 1 commande

Copiez-collez dans votre terminal : le skill s'installe dans ~/.claude/skills et Claude Code se lance directement dessus.

macOS / Linux
curl -fsSL https://raw.githubusercontent.com/khalilbenaz/claude-skills-collection/main/install.sh | sh -s -- dev-hangfire-job-scheduler --launch
Windows (PowerShell)
iex "& { $(iwr -useb https://raw.githubusercontent.com/khalilbenaz/claude-skills-collection/main/install.ps1) } dev-hangfire-job-scheduler -Launch"

🚀 Déjà installé ?

claude "/dev-hangfire-job-scheduler"

Ou tapez /dev-hangfire-job-scheduler dans une session Claude Code, ou décrivez simplement votre besoin — le skill se déclenche automatiquement via le skill-router.

🔑 Déclencheurs automatiques

Le skill s'active automatiquement quand votre demande contient :

hangfirebackground jobtâche de fond .NETjob récurrentcron job C#fire and forget

📦 Installation manuelle

git clone https://github.com/khalilbenaz/claude-skills-collection.git cp -r claude-skills-collection/skills/dev-hangfire-job-scheduler ~/.claude/skills/

Payload du plugin : skills/dev-hangfire-job-scheduler · source éditable : dev-skills/hangfire-job-scheduler

📖 Manuel

Hangfire Job Scheduler

Workflow en étapes

  1. Choisir le type de job — voir tableau ci-dessous ; le type détermine l'API.
  2. Concevoir le job — interface DI, paramètres simples, idempotence, attribut [AutomaticRetry].
  3. Configurer le storage — SQL Server (standard), Redis (haute fréquence), PostgreSQL (Hangfire.PostgreSql).
  4. Configurer le serveur — queues priorisées, WorkerCount, ServerTimeout.
  5. Enregistrer les jobs — au démarrage via IRecurringJobManager ou RecurringJob.AddOrUpdate.
  6. Protéger et déployer le dashboard — filtre d'auth, HTTPS uniquement en production.
  7. Monitorer — dashboard, logs structurés, alertes sur jobs Failed/Expired.

Critères de décision : quel type de job ?

TypeQuand l'utiliserAPI
Fire-and-forgetTraitement hors request pipeline, sans résultat attenduBackgroundJob.Enqueue
DelayedAction différée (ex: relance J+1)BackgroundJob.Schedule
RecurringBatch quotidien, rapport, purge CRONRecurringJob.AddOrUpdate
ContinuationChaînage (import → notification)BackgroundJob.ContinueJobWith
Batch (Pro)Fan-out + agrégation (Hangfire Pro requis)BatchJob.StartNew
Règle : si le job peut dépasser 30 s ou doit survivre au redémarrage du process → Hangfire plutôt que Task.Run.

Configuration complète (Program.cs)

// NuGet : Hangfire, Hangfire.SqlServer, Hangfire.AspNetCore
builder.Services.AddHangfire(config => config
    .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
    .UseSimpleAssemblyNameTypeSerializer()
    .UseRecommendedSerializerSettings()          // Newtonsoft.Json avec camelCase
    .UseSqlServerStorage(connectionString, new SqlServerStorageOptions
    {
        CommandBatchMaxTimeout       = TimeSpan.FromMinutes(5),
        SlidingInvisibilityTimeout   = TimeSpan.FromMinutes(5),
        QueuePollInterval            = TimeSpan.FromSeconds(15),
        UseRecommendedIsolationLevel = true,
        DisableGlobalLocks           = true,     // recommandé Hangfire 1.8+
        SchemaName                   = "hangfire"
    }));

builder.Services.AddHangfireServer(options =>
{
    options.Queues      = ["critical", "default", "low"];
    options.WorkerCount = Environment.ProcessorCount * 2;   // ajuster selon le workload
    options.ServerTimeout = TimeSpan.FromMinutes(5);
});

// Dashboard — TOUJOURS derrière un filtre en production
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
    Authorization   = [new HangfireRoleAuthFilter("Admin")],
    IsReadOnlyFunc  = ctx => !ctx.GetHttpContext().User.IsInRole("Admin")
});

Conception d'un job robuste

public interface IEmailJobService
{
    Task SendWelcomeEmailAsync(string userId, CancellationToken ct = default);
    Task SendMonthlyReportAsync(int month, int year, CancellationToken ct = default);
}

public class EmailJobService : IEmailJobService
{
    private readonly IEmailSender _sender;
    private readonly IUserRepository _users;

    public EmailJobService(IEmailSender sender, IUserRepository users)
        => (_sender, _users) = (sender, users);

    // Retry exponentiel, échoue proprement sur erreur métier
    [AutomaticRetry(Attempts = 3, DelaysInSeconds = [60, 300, 900],
                    OnAttemptsExceeded = AttemptsExceededAction.Fail)]
    [Queue("default")]
    public async Task SendWelcomeEmailAsync(string userId, CancellationToken ct = default)
    {
        var user = await _users.GetByIdAsync(userId, ct)
            ?? throw new InvalidOperationException($"User {userId} not found");
        await _sender.SendAsync(user.Email, "Bienvenue", "welcome-template", ct);
    }

    [AutomaticRetry(Attempts = 2)]
    [Queue("low")]
    public async Task SendMonthlyReportAsync(int month, int year, CancellationToken ct = default)
    {
        // Job long → vérifier l'annulation entre les étapes
        var users = await _users.GetAllActiveAsync(ct);
        foreach (var u in users)
        {
            ct.ThrowIfCancellationRequested();
            var report = await BuildReportAsync(u, month, year, ct);
            await _sender.SendAsync(u.Email, $"Rapport {month}/{year}", report, ct);
        }
    }
}

Enregistrement des jobs

// Fire-and-forget
BackgroundJob.Enqueue<IEmailJobService>(x => x.SendWelcomeEmailAsync(userId, CancellationToken.None));

// Delayed (dans 24 h)
BackgroundJob.Schedule<IEmailJobService>(
    x => x.SendWelcomeEmailAsync(userId, CancellationToken.None),
    TimeSpan.FromHours(24));

// Recurring — via IRecurringJobManager (testable)
services.AddScoped<IJobBootstrapper, JobBootstrapper>();

public class JobBootstrapper : IJobBootstrapper
{
    private readonly IRecurringJobManager _jobs;
    public JobBootstrapper(IRecurringJobManager jobs) => _jobs = jobs;

    public void Register()
    {
        _jobs.AddOrUpdate<IEmailJobService>(
            "monthly-report",
            x => x.SendMonthlyReportAsync(0, 0, CancellationToken.None),
            "0 8 1 * *",                        // 1er du mois à 8 h
            new RecurringJobOptions
            {
                TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Africa/Tunis")
            });
    }
}

// Continuation (chaînage)
var importId = BackgroundJob.Enqueue<IDataService>(x => x.ImportDataAsync(CancellationToken.None));
BackgroundJob.ContinueJobWith<INotificationService>(
    importId,
    x => x.NotifyImportCompleteAsync(CancellationToken.None));

Gestion avancée des erreurs

Filtre : ne pas retenter les erreurs métier

public class SmartRetryFilter : JobFilterAttribute, IElectStateFilter
{
    public void OnStateElection(ElectStateContext context)
    {
        if (context.CandidateState is FailedState { Exception: BusinessException })
            context.CandidateState = new DeletedState { Reason = "Erreur métier — pas de retry" };
    }
}

// Enregistrement global
GlobalJobFilters.Filters.Add(new SmartRetryFilter());

Retry exponentiel avec délais personnalisés

[AutomaticRetry(
    Attempts          = 5,
    DelaysInSeconds   = [30, 120, 600, 3600, 14400],   // 30 s → 4 h
    OnAttemptsExceeded = AttemptsExceededAction.Fail)]
public async Task ProcessPaymentAsync(string paymentId, CancellationToken ct = default) { ... }

Queues et priorités

// Serveur avec ordre de consommation strict
options.Queues = ["critical", "default", "low"];
// Le serveur vide "critical" avant de traiter "default", etc.

// Déploiement multi-serveurs : dédier des serveurs par queue
// Serveur 1 : critical + default
// Serveur 2 : low (batch nocturne)

Idempotence — pattern clé

public async Task ImportInvoiceAsync(string invoiceId, CancellationToken ct = default)
{
    // Vérifier si déjà traité avant tout side-effect
    if (await _db.Invoices.AnyAsync(i => i.ExternalId == invoiceId && i.Imported, ct))
        return; // idempotent : deuxième exécution sans effet

    var invoice = await _externalApi.GetInvoiceAsync(invoiceId, ct);
    await _db.Invoices.AddAsync(new Invoice { ExternalId = invoiceId, Data = invoice, Imported = true }, ct);
    await _db.SaveChangesAsync(ct);
}

Surveillance & monitoring

# Vérifier les jobs Failed depuis le dashboard
# URL : https://myapp/hangfire → onglet "Failed"

# Requête SQL de diagnostic (SQL Server)
SELECT State, COUNT(*) AS Count
FROM hangfire.Job
GROUP BY State;

# Jobs bloqués depuis > 1 h
SELECT Id, StateName, CreatedAt, DATEDIFF(MINUTE, CreatedAt, GETUTCDATE()) AS AgeMinutes
FROM hangfire.Job
WHERE StateName IN ('Processing', 'Enqueued')
  AND DATEDIFF(MINUTE, CreatedAt, GETUTCDATE()) > 60;

Intégrer avec OpenTelemetry / Serilog :

// Serilog sink — chaque job loggue son JobId automatiquement
Log.ForContext("HangfireJobId", PerformContext?.BackgroundJob.Id)
   .Information("Traitement démarré pour {UserId}", userId);

Garde-fous / Anti-patterns / Pièges

Anti-patternRisqueCorrection
Passer un DbContext ou HttpContext en paramètreSérialisation échoue / contexte périméPasser un id simple, résoudre via DI dans le job
Capturer DateTime.Now dans un job récurrentValeur figée à l'enregistrement, pas à l'exécutionCalculer la date à l'intérieur du job
Thread.Sleep dans un jobBloque un worker threadawait Task.Delay(...)
WorkerCount trop élevé sur SQL ServerContention sur les tables HangfireLimiter à 2–4× CPU ; monitorer les deadlocks
Dashboard sans auth en productionExposition des données sensibles et contrôle des jobsToujours un IDashboardAuthorizationFilter
Jobs non idempotentsDonnées dupliquées si retry ou déploiement blue/greenVérifier l'état avant chaque side-effect
Expressions CRON sans timezoneDécalage heure d'été / UTCToujours RecurringJobOptions { TimeZone = ... }
Stocker de gros payloads dans les paramètresTable hangfire.Job enfléeStocker en DB/blob, passer seulement un identifiant

Bonnes pratiques 2026