Skip to content
fullstackhero

Reference

Jobs building block

Hangfire 1.8 wiring with multi-tenant context preservation, DI-aware activator, basic-auth dashboard, telemetry filter, and stale-lock cleanup.

views 0 Last updated

The Jobs block wires Hangfire 1.8 into the kit. The interesting parts are the tenant-aware job filter (preserves multi-tenant context across enqueue → execute), the DI-aware activator (jobs get scoped service resolution), the OpenTelemetry filter (activities for every job), the basic-auth dashboard at /jobs, and a stale-lock cleanup service for after-crash recovery.

What it ships

Extensions

  • AddHeroJobs(services) — registers the Hangfire server with 5 workers, 30-second heartbeat, queues default + email, and 30-second polling. Reads DatabaseOptions:Provider to select Postgres (Hangfire.PostgreSql) or SQL Server storage. Registers FshJobActivator, FshJobFilter, LogJobFilter, HangfireTelemetryFilter, plus HangfireStaleLockCleanupService.
  • UseHeroJobDashboard(app, configuration) — wires the Hangfire dashboard at the route configured in HangfireOptions:Route (default /jobs) behind a basic-auth filter using HangfireOptions:UserName + HangfireOptions:Password.

Service interface

  • IJobService — convenience facade over Hangfire’s static BackgroundJob API: Enqueue / Enqueue<T> (with an optional queue overload), Schedule / Schedule<T> (by TimeSpan delay or DateTimeOffset), Delete, Requeue. All return/accept Hangfire job-id strings. Recurring jobs go through Hangfire’s IRecurringJobManager directly.
  • HangfireService : IJobService — implementation.

Filters

  • FshJobActivator : JobActivator — resolves job classes from a fresh IServiceScope per job, restoring the captured tenant (via Finbuckle’s IMultiTenantContextSetter) and user id (via ICurrentUserInitializer) before resolution — so the job’s scoped DbContext is built with the right tenant.
  • FshJobFilter : IClientFilter — at enqueue time, stamps the current tenant + user id onto the job as parameters (skipped when there’s no HTTP context, e.g. recurring-job creation).
  • LogJobFilter — logs every state transition (Enqueued, Processing, Succeeded, Failed) through ILogger.
  • HangfireTelemetryFilter — opens an OpenTelemetry activity per job with job.name, job.queue, job.id tags.
  • HangfireCustomBasicAuthenticationFilter — gates the dashboard via basic auth.

Hosted service

  • HangfireStaleLockCleanupService — runs a few seconds after host startup, queries the storage for orphaned locks (held by a server that crashed), and releases them.

Options

  • HangfireOptionsUserName (required, min 3 chars), Password (required, min 12 chars — both validated at startup, no safe default), Route (default /jobs).

How modules consume Jobs

Inject IJobService to enqueue:

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);
}
}

Or register a recurring job — the kit’s pattern is to resolve IRecurringJobManager inside the module’s MapEndpoints (it’s null when jobs are disabled, so guard it):

var jobManager = endpoints.ServiceProvider.GetService<IRecurringJobManager>();
if (jobManager is not null)
{
// Fire at 00:05 UTC on the 1st of every month; the job bills the previous period.
jobManager.AddOrUpdate(
"billing-monthly-invoices",
Job.FromExpression<MonthlyInvoiceJob>(j => j.RunAsync(CancellationToken.None)),
"5 0 1 * *",
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
}

The Billing module ships MonthlyInvoiceJob (monthly); the Files module ships PurgeOrphanedFilesJob (hourly) and PurgeDeletedFilesJob (daily, 03:30 UTC); the Auditing module ships AuditRetentionJob (cron from AuditRetentionOptions, no-op until enabled).

Configuration

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

The storage provider falls out of DatabaseOptions:Provider — PostgreSQL routes to Hangfire.PostgreSql; MSSQL routes to the SQL Server storage.

How to extend

Add a new queue

Hangfire dispatches by queue name. Register your job with [Queue("priority")] and add the queue name to the server’s queue array in AddHeroJobs:

services.AddHangfireServer(opts => opts.Queues = new[] { "default", "email", "priority" });

Decorate jobs with a custom filter

Implement IServerFilter and register it in DI; AddHangfireServer will pick it up. The kit’s filters are templates — read FshJobFilter.cs for the lifecycle hooks (OnPerforming, OnPerformed).

Use Hangfire’s [AutomaticRetry] attribute

For per-job retry policies:

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

The Webhooks module uses this exact pattern.

Gotchas

  • Hangfire serializes arguments when enqueuing. 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 hold scoped state across jobs; each invocation gets a fresh DbContext.
  • Dashboard auth is basic. Use HTTPS in production. Consider IP whitelisting in your reverse proxy in addition.
  • Stale-lock cleanup runs after startup. It defers a few seconds so it doesn’t interfere with legitimate startup-time lock acquisition; it’s best-effort and logs a warning with the count when it actually releases something.

Critical files

  • src/BuildingBlocks/Jobs/Extensions.cs
  • src/BuildingBlocks/Jobs/Services/HangfireService.cs
  • src/BuildingBlocks/Jobs/FshJobFilter.cs
  • src/BuildingBlocks/Jobs/FshJobActivator.cs
  • src/BuildingBlocks/Jobs/HangfireStaleLockCleanupService.cs