Skip to content

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 TaskType in priority order
  • Claims tasks with Processing status 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.EmailSend and TaskStatus.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 LastProcessedDate to prevent duplicates

PendingUsersReminderJob

Notifies admins about users waiting for approval:

  • Finds users with RegistrationStatus.Pending and confirmed email
  • Only triggers after PendingUserNotificationDays have passed
  • Sends single email to all admins with list of pending users
  • Marks users with PendingReminderSentAt to prevent repeat notifications

ExpireUnconfirmedRegistrationsJob

Handles registration expiry workflow:

  1. Warning phase — Sends warning email to users approaching expiry with new confirmation link
  2. Expiry phase — Sets RegistrationStatus.Declined for 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:

  1. Navigate to /hangfire (requires Admin role)
  2. Click Recurring Jobs in the sidebar
  3. Find the job you want to trigger
  4. 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:

  1. Create test data (e.g., a pending user registration)
  2. Open Hangfire dashboard
  3. Trigger the relevant job manually
  4. Verify results in the database or UI