Background Jobs¶
Hangfire-based background job processing.
Overview¶
flowchart TB
subgraph Triggers
A[Cron Schedule]
B[API Enqueue]
end
subgraph Hangfire
C[Job Queue]
D[Job Processor]
end
subgraph Jobs
E[CleanupJob]
F[EmailSendJob]
G[ProcessRecurringTransactionsJob]
H[PendingUsersReminderJob]
I[ExpireUnconfirmedRegistrationsJob]
end
A --> C
B --> C
C --> D
D --> E
D --> F
D --> G
D --> H
D --> I Job Base Classes¶
JobBase¶
All jobs extend JobBase<TJob>:
public abstract class JobBase<TJob> : IJob where TJob : JobBase<TJob>
{
public string CronExpression =>
_configuration.GetValue<string>($"Hangfire:JobsCron:{GetType().Name}") ?? CronNever;
public abstract Task ExecuteAsync();
public async Task Process(CancellationToken cancellationToken = default)
{
CancellationToken = cancellationToken;
var jobName = GetType().Name;
try
{
Logger.LogInformation("[{JobName}] Starting job execution", jobName);
await ExecuteAsync();
Logger.LogInformation("[{JobName}] Job completed successfully", jobName);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
Logger.LogWarning("[{JobName}] Job was cancelled", jobName);
throw;
}
catch (Exception ex)
{
Logger.LogError(ex, "[{JobName}] Job failed with error", jobName);
throw;
}
}
}
BackgroundTaskProcessJob¶
Jobs that process BackgroundTask entities extend BackgroundTaskProcessJob<TJob>:
public abstract class BackgroundTaskProcessJob<TJob> : JobBase<TJob>
where TJob : BackgroundTaskProcessJob<TJob>
{
protected abstract TaskType TaskType { get; }
protected abstract int BatchSize { get; }
public override async Task ExecuteAsync()
{
var now = DateTimeProvider.GetDateTime();
for (var i = 0; i < BatchSize; i++)
{
var task = await Context.BackgroundTasks
.Where(t => t.Type == TaskType
&& t.Status == TaskStatus.Pending
&& t.ScheduledAt <= now)
.OrderByDescending(t => t.Priority)
.ThenBy(t => t.CreatedDate)
.FirstOrDefaultAsync(CancellationToken);
if (task == null) break;
// Claim task with optimistic concurrency
task.Status = TaskStatus.Processing;
try
{
await Context.SaveChangesAsync(CancellationToken);
}
catch (DbUpdateConcurrencyException)
{
Context.Entry(task).State = EntityState.Detached;
continue; // Another worker claimed it
}
await ProcessTaskAsync(task, now);
await Context.SaveChangesAsync(CancellationToken);
}
}
protected abstract Task ProcessTaskAsync(BackgroundTask task, DateTime now);
}
Key features:
- Fetches pending tasks by
TaskTypein priority order - Claims tasks with
Processingstatus before processing - Handles concurrent workers via
DbUpdateConcurrencyException - Detaches stale entities on concurrency conflicts
Jobs¶
CleanupJob¶
Single cleanup job that handles multiple tasks:
| Task | Purpose |
|---|---|
CleanupExpiredSessionsAsync | Delete expired refresh tokens |
EmptyTrashBinAsync | Permanently delete soft-deleted markers after retention period |
CleanupOrphanedFilesAsync | Remove files with ProcessingStatus.Pending that exceeded upload timeout |
CleanupCompletedEmailTasksAsync | Delete completed email tasks older than retention period |
ResetStuckProcessingTasksAsync | Reset tasks stuck in Processing status back to Pending (uses UpdatedDate to detect, 30 min threshold) |
Each task runs independently - if one fails, others continue. Failures aggregate into AggregateException.
EmailSendJob¶
Extends BackgroundTaskProcessJob to process email queue from BackgroundTask table. See Email Outbox for details.
public sealed class EmailSendJob : BackgroundTaskProcessJob<EmailSendJob>
{
protected override TaskType TaskType => TaskType.EmailSend;
protected override int BatchSize => TaskConstants.EmailSend.BatchSize;
protected override async Task ProcessTaskAsync(BackgroundTask task, DateTime now)
{
// Deserialize email data, send via Resend, handle retries
}
}
- Processes tasks with
TaskType.EmailSendandTaskStatus.Pending - Priority ordering with retry logic
- Exponential backoff on failures
ProcessRecurringTransactionsJob¶
Creates transactions from RecurringTransaction definitions:
- Checks each enabled recurring transaction
- Supports monthly, weekly, and yearly frequencies
- Handles edge cases (last day of month, leap years)
- Tracks
LastProcessedDateto prevent duplicates
PendingUsersReminderJob¶
Notifies admins about users waiting for approval:
- Finds users with
RegistrationStatus.Pendingand confirmed email - Only triggers after
PendingUserNotificationDayshave passed - Sends single email to all admins with list of pending users
- Marks users with
PendingReminderSentAtto prevent repeat notifications
ExpireUnconfirmedRegistrationsJob¶
Handles registration expiry workflow:
- Warning phase — Sends warning email to users approaching expiry with new confirmation link
- Expiry phase — Sets
RegistrationStatus.Declinedfor expired registrations
Uses BackgroundTask to queue expiry emails.
Cron Configuration¶
Schedules are configurable in appsettings.json under Hangfire:JobsCron. Default values:
{
"Hangfire": {
"JobsCron": {
"CleanupJob": "0 23 * * *",
"EmailSendJob": "*/2 * * * *",
"ProcessRecurringTransactionsJob": "5 0 * * *",
"PendingUsersReminderJob": "0 6 * * *",
"ExpireUnconfirmedRegistrationsJob": "0 5 * * *"
}
}
}
Override any entry to change the schedule without code changes. Jobs without cron config default to CronNever (0 0 31 2 * — Feb 31st, never runs).
Job Registration¶
RecurringJob.AddOrUpdate<CleanupJob>(
nameof(CleanupJob),
job => job.Process(CancellationToken.None),
job.CronExpression);
Dashboard¶
Hangfire dashboard available at /hangfire (admin-only).
Access controlled via JWT stored in hangfire_jwt cookie.
Manual Job Triggering¶
Jobs can be triggered manually via the dashboard — useful for testing in development or forcing immediate execution in production.
To trigger a job manually:
- Navigate to
/hangfire(requires Admin role) - Click Recurring Jobs in the sidebar
- Find the job you want to trigger
- Click Trigger now button
Development configuration:
In appsettings.Development.json, most jobs use the never-run cron expression (0 0 31 2 * — Feb 31st, impossible date). This prevents unwanted job execution during development while allowing manual testing via the dashboard. Only ProcessRecurringTransactionsJob runs automatically (daily at 00:05) to test recurring transaction creation.
Typical development workflow:
- Create test data (e.g., a pending user registration)
- Open Hangfire dashboard
- Trigger the relevant job manually
- Verify results in the database or UI
Related¶
- Email Outbox — BackgroundTask-based email queue
- Background Jobs — Conceptual overview