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

UseHeroPlatform calls UseHeroJobDashboard for you, mounting the dashboard at HangfireOptions:Route (default /jobs).

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 Enqueue / Schedule:

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

Job (recurring job id)ModuleCron
MonthlyInvoiceJob (billing-monthly-invoices)Billing5 0 1 * * — 00:05 UTC on the 1st of each month
PurgeOrphanedFilesJob (files-purge-orphans)Files0 * * * * — hourly
PurgeDeletedFilesJob (files-purge-deleted)Files30 3 * * * — 03:30 UTC daily
AuditRetentionJob (auditing-retention)Auditing30 3 * * * default (configurable; the job is a no-op until Auditing:Retention:Enabled is true)
TenantExpiryScanJob (tenant-expiry-scan)Multitenancy0 2 * * * — 02:00 UTC daily

The eventing outbox is not a Hangfire job — it’s dispatched by the framework’s OutboxDispatcherHostedService. (The host even ships a cleanup service that removes retired {module}-outbox-dispatcher recurring jobs from older deployments.)

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 on the job method:

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

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. The Files purge jobs carry their own tuned attributes ([AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 120, 600])] for orphans; 2 attempts for deleted-file purges).

Dashboard

UseHeroJobDashboard (called inside UseHeroPlatform) mounts the Hangfire dashboard at HangfireOptions:Route (default /jobs), gated by HangfireCustomBasicAuthenticationFilter.

{
"HangfireOptions": {
"UserName": "admin",
"Password": "set-via-secrets", // min 12 chars — validated at startup
"Route": "/jobs"
}
}

There are no safe defaults: UserName (min 3 chars) and Password (min 12 chars) are [Required] and validated at startup via ValidateDataAnnotations().ValidateOnStart() — the host won’t boot with jobs enabled and empty credentials. Local dev gets admin / Password123! from appsettings.Development.json (the Aspire AppHost injects the same); set real values via env vars or a vault in prod. 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 ~5s 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.