Industrial robots in a car assembly line

How to replace BackgroundServices with a lite Hangfire

This post is part of my Advent of Code 2022.

I like ASP.NET Core BackgroundServices. I’ve used them in one of my client’s projects to run recurring operations outside the main ASP.NET Core API site. Even for small one-time operations, I’ve run them in the same API site.

There’s one catch. We have to write our own retrying, multi-threading, and reporting mechanism. BackgroundServices are a lightweight alternative to run background tasks.

Lite Hangfire

These days, a coworker came up with the idea to use a “lite” Hangfire to replace ASP.NET Core BackgroundServices. By “lite,” he meant an in-memory, single-thread Hangfire configuration.

Let’s create an ASP.NET Core API site and install these NuGet packages:

1. Register Hangfire

In the Program.cs file, let’s register the Hangfire server, dashboard, and recurring jobs. Like this,

using Hangfire;
using LiteHangfire.Extensions;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.ConfigureHangfire();
//               ^^^^^

var app = builder.Build();

app.UseAuthorization();
app.MapControllers();

app.UseHangfireDashboard();
app.MapHangfireDashboard();
//  ^^^^^
app.ConfigureRecurringJobs();
//  ^^^^^

app.Run();

To make things cleaner, we can use extension methods to keep all Hangfire configurations in a single place. Like this,

using Hangfire;
using Hangfire.Console;
using Hangfire.MemoryStorage;
using RecreatingFilterScenario.Jobs;

namespace LiteHangfire.Extensions;

public static class ServiceCollectionExtensions
{
    public static void ConfigureHangfire(this IServiceCollection services)
    {
        services.AddHangfire(configuration =>
        {
            configuration.UseMemoryStorage();
            //            ^^^^^
            // Since we have good memory
            configuration.UseConsole();
            //            ^^^^^

        });
        services.AddHangfireServer(options =>
        {
            options.SchedulePollingInterval = TimeSpan.FromSeconds(5);
            //      ^^^^^
            // For RecurringJobs: Delay between retries.
            // By default: 15sec
            options.WorkerCount = 1;
            //      ^^^^^
            // Number of worker threads.
            // By default: min(processor count * 5, 20)
        });

        GlobalJobFilters.Filters.Add(new AutomaticRetryAttribute
        {
            Attempts = 1
            // ^^^^^
            // Retry count.
            // By default: 10
        });
    }

    public static void ConfigureRecurringJobs(this WebApplication app)
    {
        //var config = app.Services.GetRequiredService<IOptions<MyRecurringJobOptions>>().Value;
        // ^^^^^^^^^
        // To read the cron expression from a config file

        RecurringJob.AddOrUpdate<ProducerRecurringJob>(
            ProducerRecurringJob.JobId,
            x => x.DoSomethingAsync(),
            "0/1 * * * *");
            // ^^^^^^^^^
            // Every minute. Change it to suit your own needs

        RecurringJob.Trigger(ProducerRecurringJob.JobId);
    }
}

Notice that we used the UseMemoryStorage() method to store jobs in memory instead of in a database and the UseConsole() to bring color to our logging messages in the Dashboard.

Car factory
Photo by carlos aranda on Unsplash

2. Change some Hangfire parameters

Then, when we registered the Hangfire server, we used these parameters:

As an aside, I also discovered these settings:

Then, we registered the number of retry attempts. By default, Hangfire retries jobs 10 times. Source

3. Write “Producer” and “Consumer” jobs

The next step was to register a recurring job as a “producer.” It looks like this,

using Hangfire;
using Hangfire.Console;
using Hangfire.Server;

namespace LiteHangfire.Jobs;

public class ProducerRecurringJob
{
    public const string JobId = nameof(ProducerRecurringJob);

    private readonly IBackgroundJobClient _backgroundJobClient;
    private readonly ILogger<ProducerRecurringJob> _logger;

    public ProducerRecurringJob(IBackgroundJobClient backgroundJobClient,
                                ILogger<ProducerRecurringJob> logger)
    {
        _backgroundJobClient = backgroundJobClient;
        _logger = logger;
    }

    public async Task DoSomethingAsync()
    {
        _logger.LogInformation("Running recurring job at {now}", DateTime.UtcNow);

        // Beep, beep, boop...
        await Task.Delay(1_000);

        // We could read pending jobs from a database, for example
        foreach (var item in Enumerable.Range(0, 5))
        {
            _backgroundJobClient.Enqueue<WorkerJob>(x => x.DoSomeWorkAsync(null));
            // ^^^^^
        }
    }
}

Inside this recurring job, we can read pending jobs from a database and enqueue a new worker job for every pending job available.

And a sample worker job that uses Hangfire.Console looks like this,

public class WorkerJob
{
    public async Task DoSomeWorkAsync(PerformContext context)
    {
        context.SetTextColor(ConsoleTextColor.Blue);
        context.WriteLine("Doing some work at {0}", DateTime.UtcNow);

        // Beep, beep, boop...
        await Task.Delay(3_000);
    }
}

Notice that we expect a PerformContext as a parameter to change the color of the logging message. When we enqueued the worker jobs, we passed null as context, then Hangfire uses the right instance when running our jobs. Source.

Voilà! That’s how to use a lite Hangfire to replace BackgroundServices without adding too much overhead or a new database to store jobs. With the advantage that Hangfire has recurring jobs, retries, and a Dashboard out of the box.

To read more content about ASP.NET Core, check how to write tests for HttpClient and how to test an ASP.NET filter.

Happy coding!