Skip to content
fullstackhero

Concept

Background jobs

Hangfire 1.8 wired with tenant context preservation, DI-aware scoped activation, OpenTelemetry instrumentation, and a basic-auth-gated dashboard.

views 0 Last updated

Hangfire 1.8 powers every background job in fullstackhero. The Jobs building block wraps it with four kit-specific filters: FshJobFilter preserves tenant + user context across enqueue → execute, FshJobActivator resolves jobs from a scoped DI container per execution, HangfireTelemetryFilter opens an OpenTelemetry activity per job, and LogJobFilter logs every state transition.

How it’s wired

builder.AddHeroPlatform(o => o.EnableJobs = true); // turns on AddHeroJobs
app.UseHeroJobDashboard(builder.Configuration); // mounts /hangfire

Storage provider falls out of DatabaseOptions:Provider:

  • PostgreSQLHangfire.PostgreSql
  • MSSQL → SQL Server storage

The kit defaults to 5 workers, 30-second heartbeat, queues default + email, 30-second polling.

Enqueueing a job

Inject IJobService and call EnqueueAsync / ScheduleAsync:

public sealed class SendInvoiceEmailCommandHandler(IJobService jobs)
: ICommandHandler<SendInvoiceEmailCommand, Unit>
{
public ValueTask<Unit> Handle(SendInvoiceEmailCommand cmd, CancellationToken ct)
{
jobs.Enqueue<SendInvoiceEmailJob>(j => j.RunAsync(cmd.InvoiceId, CancellationToken.None));
return ValueTask.FromResult(Unit.Value);
}
}

Hangfire serializes the call signature; the activator resolves SendInvoiceEmailJob from a fresh DI scope when the job dequeues. The job can inject DbContext, IMailService, ICurrentUser, etc.

Recurring jobs in the kit

JobModuleCron
MonthlyInvoiceJobBilling5 0 1 * * — 00:05 UTC on the 1st of each month
PurgeOrphanedFilesJobFileshourly
PurgeDeletedFilesJobFiles30 3 * * * — 03:30 UTC daily
AuditRetentionJobAuditing30 3 * * * — daily (opt-in via Auditing:Retention:Enabled)
identity-outbox-dispatcherIdentityevery minute

Register your own with IRecurringJobManager:

recurringJobs.AddOrUpdate<MyHourlyJob>(
"my-hourly-job",
job => job.RunAsync(CancellationToken.None),
"0 * * * *", // top of every hour
TimeZoneInfo.Utc);

Retry policies

Per-job retry via the [AutomaticRetry] attribute:

[AutomaticRetry(Attempts = 4, OnAttemptsExceeded = AttemptsExceededAction.Fail,
DelaysInSeconds = new[] { 30, 120, 600, 3600 })]
public sealed class WebhookDispatchJob
{
public async Task RunAsync(/* ... */) { /* ... */ }
}

The Webhooks module uses this exact pattern — 5 attempts total (1 initial + 4 retries) with 30 s / 2 min / 10 min / 1 h exponential backoff.

Dashboard

UseHeroJobDashboard(app, config) mounts the Hangfire dashboard at HangfireOptions:Route (default /hangfire), gated by HangfireCustomBasicAuthenticationFilter.

{
"HangfireOptions": {
"UserName": "admin",
"Password": "set-via-secrets",
"Route": "/hangfire"
}
}

Always HTTPS in production. Consider IP whitelisting in the reverse proxy on top of basic auth.

Gotchas

  • Hangfire serializes arguments. Pass primitives (Guid, string, int) — not large object graphs. If you need state, look it up inside the job from the DI-resolved DbContext.
  • FshJobActivator creates a new scope per job. Don’t try to share state via scoped services across job invocations; each gets a clean container. Use Valkey / DB for cross-job state.
  • Stale-lock cleanup runs ~10s after host startup. HangfireStaleLockCleanupService releases locks from servers that crashed without releasing. The delay avoids interfering with legitimate startup-time job acquisition.
  • Background jobs and tenant context. Enqueuing happens inside a request scope (so ICurrentUser works). Execution happens on a background thread (so it doesn’t). FshJobFilter bridges this — but if you write your own filter or skip the kit’s activator, restore the tenant context manually.