Tales from the .NET Migration Trenches - Hangfire

Posts in this series:

In the last post, we encountered our first instance of shared runtime data between our different ASP.NET 4.8 and ASP.NET Core applications, in Session State. There are other mechanisms to store state in ASP.NET 4.8 (such as Application state), but Session is the most common. In this post, we'll look at another instance of shared state that isn't built in to ASP.NET, but I find quite common - Hangfire.

Hangfire is an easy way to perform background tasks/processes in a .NET web application, and it also supports persistent storage for both the jobs and queues. I use it quite a lot in applications where I don't want to introduce a separate host for processing messages, or introduce a specific queue/broker for background jobs. Hangfire supports fire-and-forget jobs as well as "cron"-based jobs. It also provides a nice dashboard where you can see completed and failed jobs, with the option of retrying failed jobs as desired.

Depending on how you're using Hangfire, it introduces a unique challenge when migrating from .NET 4.8 to .NET 6/7/8. Hangfire supports both frameworks, but as usual, the devil is in the details. We want to be able to start/consume jobs from both sides AND ensure our job executes at most once.

First, let's look to see how we configure and use Hangfire today.

ASP.NET 4.8 Hangfire Usage

In our OWIN startup in the ASP.NET 4.8 application, we find our Hangfire configuration:

GlobalConfiguration.Configuration
    .UseSqlServerStorage("SchoolContext")
    .UseStructureMapActivator(IoC.Container)
    ;

app.UseHangfireDashboard();
app.UseHangfireServer();

We can see here that we're using SQL Server for our storage (jobs and queues), and that we're using a DI container (StructureMap) for activating/instantiating jobs. We don't see it explicitly configured but our job is using the default queue, named default.

Our jobs can be enqueued anywhere really, from controllers to services to startup. For anything that migrates to ASP.NET Core that uses Hangfire, we'll have to migrate that usage as well. Here's one usage:

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Edit(Edit.Command command)
{
    await _mediator.Send(command);

    _backgroundJobClient.Enqueue(() => LogEdit(command));

    return this.RedirectToActionJson(c => c.Index(null));
}

[NonAction]
public void LogEdit(Edit.Command command)
{
    _logger.Information($"Editing student {command.ID}");
}

It's completely trivial, but let's assume the background job is actually doing something interesting, like sending emails or SMS messages.

If we were to migrate this by itself over to ASP.NET Core, we'll immediately run into an issue - Hangfire is now running in two places - ASP.NET and ASP.NET Core, and if we do nothing additional, Hangfire in each server will try to consume those jobs. Unfortunately, it might not be able to execute those jobs. In the above example, the job exists simply as a method from my controller - a perfectly valid way of using Hangfire. If this method only exists in one of the web applications, the other web application won't be able to execute the job and it will wind up failing.

Hangfire does support web farm scenarios and the competing consumer pattern, so we still know that only one side or the other will pick up the job. But it might not be able to execute it if the job code isn't there.

We could fix this by migrating all of our job code to the "shared" assembly first, but this might be a complex undertaking especially if we're using the pattern above. Instead, we can create separate queues for each host - ASP.NET 4.8 and ASP.NET Core, and ensure the job is queued to the host where that job code lives.

When both live in ASP.NET Core:

\

When the initiator is ASP.NET Core and the job lives in ASP.NET 4.8:

And the reverse:

And finally solely ASP.NET 4.8:

With this setup, the job initiator must "know" where the job code lives. This might seem like unnecessary coupling, but keep in mind this is transitional configuration and we won't need to have this knowledge baked in once all of the jobs and initiators are migrated.

Configuring for Multiple Hosts

Initially, we did not specify any queue in our Hangfire configuration. Now, we'll be explicit in ASP.NET 4.8:

app.UseHangfireServer(new BackgroundJobServerOptions
{
    Queues = new[] { Queues.Default }
});

And after pulling in the appropriate packages to ASP.NET Core, we configure our startup there with the other queue:

builder.Services.AddHangfire(cfg =>
{
    cfg.UseSqlServerStorage(
        builder.Configuration.GetConnectionString("SchoolContext"));
});
builder.Services.AddHangfireServer(options =>
    options.Queues = new[] { Queues.DefaultCore }
);

I could migrate the Hangfire dashboard over to ASP.NET Core, but I left it alone for now. The YARP piece will take care of that for now.

For jobs that start and stay inside of one host, there's nothing we need to do to specify a queue. However, for jobs that cross host boundaries, we'll specify the queue name in the job:

[NonAction]
[Queue(Queues.DefaultCore)]
public void LogEdit(Edit.Command command)
{
    _logger.Information($"Editing student {command.ID}");
}

This ensures that the jobs start and stop where they're supposed to, and are only executed at most once. Once all of our jobs are migrated, we can rename the queue to the default name (as long as we've drained our job queues beforehand).

So far, all of our actions don't require a logged in user. In the next post, we'll tackle authentication.