| name | dev-hangfire-job-scheduler |
| description | Planification et gestion de background jobs avec Hangfire en .NET. Patterns de retry, scheduling récurrent, queues, monitoring, idempotence et bonnes pratiques 2026. À utiliser quand l'utilisateur travaille avec Hangfire, des tâches de fond ou du job scheduling en .NET. Se déclenche aussi avec "hangfire", "background job", "tâche de fond .NET", "job récurrent", "cron job C#", "fire and forget". Also triggers on "Hangfire jobs", "background jobs in .NET", "recurring job scheduling". |
Hangfire Job Scheduler
Workflow en étapes
- Choisir le type de job — voir tableau ci-dessous ; le type détermine l'API.
- Concevoir le job — interface DI, paramètres simples, idempotence, attribut
[AutomaticRetry].
- Configurer le storage — SQL Server (standard), Redis (haute fréquence), PostgreSQL (Hangfire.PostgreSql).
- Configurer le serveur — queues priorisées,
WorkerCount, ServerTimeout.
- Enregistrer les jobs — au démarrage via
IRecurringJobManager ou RecurringJob.AddOrUpdate.
- Protéger et déployer le dashboard — filtre d'auth, HTTPS uniquement en production.
- Monitorer — dashboard, logs structurés, alertes sur jobs Failed/Expired.
Critères de décision : quel type de job ?
| Type | Quand l'utiliser | API |
|---|
| Fire-and-forget | Traitement hors request pipeline, sans résultat attendu | BackgroundJob.Enqueue |
| Delayed | Action différée (ex: relance J+1) | BackgroundJob.Schedule |
| Recurring | Batch quotidien, rapport, purge CRON | RecurringJob.AddOrUpdate |
| Continuation | Chaî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)
builder.Services.AddHangfire(config => config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseSqlServerStorage(connectionString, new SqlServerStorageOptions
{
CommandBatchMaxTimeout = TimeSpan.FromMinutes(5),
SlidingInvisibilityTimeout = TimeSpan.FromMinutes(5),
QueuePollInterval = TimeSpan.FromSeconds(15),
UseRecommendedIsolationLevel = true,
DisableGlobalLocks = true,
SchemaName = "hangfire"
}));
builder.Services.AddHangfireServer(options =>
{
options.Queues = ["critical", "default", "low"];
options.WorkerCount = Environment.ProcessorCount * 2;
options.ServerTimeout = TimeSpan.FromMinutes(5);
});
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);
[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)]
[]
{
users = _users.GetAllActiveAsync(ct);
( u users)
{
ct.ThrowIfCancellationRequested();
report = BuildReportAsync(u, month, year, ct);
_sender.SendAsync(u.Email, , report, ct);
}
}
}
Enregistrement des jobs
BackgroundJob.Enqueue<IEmailJobService>(x => x.SendWelcomeEmailAsync(userId, CancellationToken.None));
BackgroundJob.Schedule<IEmailJobService>(
x => x.SendWelcomeEmailAsync(userId, CancellationToken.None),
TimeSpan.FromHours(24));
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 * *",
new RecurringJobOptions
{
TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Africa/Tunis")
});
}
}
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" };
}
}
GlobalJobFilters.Filters.Add(new SmartRetryFilter());
Retry exponentiel avec délais personnalisés
[AutomaticRetry(
Attempts = 5,
DelaysInSeconds = [30, 120, 600, 3600, 14400],
OnAttemptsExceeded = AttemptsExceededAction.Fail)]
public async Task ProcessPaymentAsync(string paymentId, CancellationToken ct = default) { ... }
Queues et priorités
options.Queues = ["critical", "default", "low"];
Idempotence — pattern clé
public async Task ImportInvoiceAsync(string invoiceId, CancellationToken ct = default)
{
if (await _db.Invoices.AnyAsync(i => i.ExternalId == invoiceId && i.Imported, ct))
return;
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
SELECT State, COUNT(*) AS Count
FROM hangfire.Job
GROUP BY State;
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 :
Log.ForContext("HangfireJobId", PerformContext?.BackgroundJob.Id)
.Information("Traitement démarré pour {UserId}", userId);
Garde-fous / Anti-patterns / Pièges
| Anti-pattern | Risque | Correction |
|---|
Passer un DbContext ou HttpContext en paramètre | Sérialisation échoue / contexte périmé | Passer un id simple, résoudre via DI dans le job |
Capturer DateTime.Now dans un job récurrent | Valeur figée à l'enregistrement, pas à l'exécution | Calculer la date à l'intérieur du job |
Thread.Sleep dans un job | Bloque un worker thread | await Task.Delay(...) |
WorkerCount trop élevé sur SQL Server | Contention sur les tables Hangfire | Limiter à 2–4× CPU ; monitorer les deadlocks |
| Dashboard sans auth en production | Exposition des données sensibles et contrôle des jobs | Toujours un IDashboardAuthorizationFilter |
| Jobs non idempotents | Données dupliquées si retry ou déploiement blue/green | Vérifier l'état avant chaque side-effect |
| Expressions CRON sans timezone | Décalage heure d'été / UTC | Toujours RecurringJobOptions { TimeZone = ... } |
| Stocker de gros payloads dans les paramètres | Table hangfire.Job enflée | Stocker en DB/blob, passer seulement un identifiant |
Bonnes pratiques 2026
- Utiliser
IRecurringJobManager injecté plutôt que la méthode statique RecurringJob (testabilité).
- Avec .NET 8+ et
IHostedService, démarrer JobBootstrapper.Register() dans IHostApplicationLifetime.ApplicationStarted.
- Pour la haute disponibilité : plusieurs instances du serveur Hangfire pointant le même storage ; Hangfire gère les verrous.
- Activer
DisableGlobalLocks = true (SQL Server 1.8+) pour réduire la contention.
- En production, activer la rétention :
GlobalJobFilters.Filters.Add(new ProlongExpirationTimeAttribute(TimeSpan.FromDays(7))).
- Tester les jobs avec
BackgroundJobClientFake (Hangfire.InMemory) ou un mock de l'interface de service.
Communication Rules — MANDATORY
- Ultra-concise. No filler, no preamble, no pleasantries.
- Never say "happy to help", "sure!", "great question", "let me", or similar.
- Tool first, talk second. Act before explaining.
- Result first. Lead with outcome, not process.
- Stop when done. No summary, no recap, no trailing commentary.
- No politeness wrappers. Direct and blunt.
- Minimum words. If one word works, do not use ten.
- No unsolicited explanations.
- No emoji unless asked.