Hangfire Background Tasks & Context

Hey! Where’s the context?

If you're not familiar with Hangfire, it's a background job runner. Long running tasks enqueue to a temporary storage medium, then dequeued and processed. If you've ever used Hangfire you may have found out that your background code lost access to the app context. This post will outline the issue and one possible solution.

Let's first take a look at how Hangfire works. It serializes the parameters passed to the function you call to run on a background thread. As jobs are ran, job parameters get deserialized and passed back into your function.

Hangfire is running your code on a thread outside the context of your application. Ambient contexts such as HttpContext are unavailable. This is the reason you can't access the apps context from within a background job.

One simple solution is to pass values from the context into your job as a parameter. This is simple, but if you have a large application with several background tasks it may not be a good solution. Do you want to add another parameter to all Hangfire jobs?

There is another strategy we can use to get context data into Hangfire jobs. Hangfire, like mediatr or the .net webstack has a pipeline. Using filters provided in the Hangfire pipeline, job data can be enhanced inline.

Filters

Pipeline actions are abstracted to just two interfaces: IClientFilter and IServerFilter. When jobs are enqueued, filters of type IClientFilter are invoked.

/// <summary> Client filter runs before the Hangfire job runs. </summary>
public class ClientExampleFilter : IClientFilter {
  private readonly IUserService _userService;

  public ClientExampleFilter(IServiceScopeFactory serviceScopeFactory) {
    var scopeFactory = serviceScopeFactory.CreateScope();
    _userService = scopeFactory.ServiceProvider.GetService<IUserService>();
  }

  public void OnCreated(CreatedContext filterContext) {}

  public void OnCreating(CreatingContext filterContext) {
    if (filterContext == null) {
      throw new ArgumentNullException(nameof(filterContext));
    }

    var user = filterContext.GetJobParameter<string>("User");

    if (string.IsNullOrEmpty(user)) {
      var userVal = _userService.GetCurrentUser();
      filterContext.SetJobParameter("User", userVal);
    }
  }
}

Filter values are pushed into jobs as parameters when enqueued and picked back up when ran. Jobs filters can inherit from JobFilterAtrribute and be scoped per job. Or they can be registered globally.

The interface for IServerFilter matches that of IClient filter. The only difference is when the filter runs. IServerFilter implementations are ran when the job is invoked. Look at the client example and then imagine the IServerFilter code ;^)

The Hangfire documentation gives an example of filters, it’s worth a read. However, it does not give an example of how to handle dependency injection. In the example above note that IServiceFactoryScope gets injected. A new scope is created from that factory, and services are resolved off that scope. This needs done because of how dependency resolution works in the Hangfire pipeline. Resolving scoped services directly will result in error.

Note: Services are scoped to the lifetime of the Hangfire job. If you have nested background jobs, the nested jobs hitting the filter will have a null context.

Activators

Hangfire, has another concept called an activator. Activators, like filters may be set per job or registered globally. They give access to the jobs context, parameters, method invoked, and the DI scope used for the job. We can use the activator to pull values out of the jobs parameters and do something with it. Below is an example where a job parameter is pulled from the current job and used to set a property on a scoped service.

  /// <summary>
/// Handles DI injection activation.
/// </summary>
public class ExampleJobActivator : AspNetCoreJobActivator {
  public ExampleJobActivator(
      [ NotNull ] IServiceScopeFactory serviceScopeFactory)
      : base(serviceScopeFactory) {}

  public override JobActivatorScope BeginScope(JobActivatorContext context) {
    var scope = base.BeginScope(context);

    var user = context.GetJobParameter<string>("User");

    var userProvider = (IUserProvider) scope.Resolve(typeof(IUserProvider));
    userProvider.CurrentUser = user;

    return scope;
  }
}

Here you can see we are overriding AspNetCoreJobActivator. If you aren’t using .net core, you may need to override JobActivator. If that is the case, you may also need to override JobActivatorScope.

Configuring Hangfire

In ASP.Net Core, Hangifre is configured with a set of extensions methods. These methods dangle off IServiceCollection. This familiar builder pattern makes setup simple. I was unable to find an example that showed how to get access to the DI container. Thankfully, it’s simple. The method AddHangfire() has an override that includes ‘provider’. This gives access to the DI container allowing IServiceScopeFactory to be injected into the filter and activator instances.

/// <summary>
/// Hangfire Service Config
/// </summary>
/// <param name="services"></param>
public void ConfigureHangfireServices(IServiceCollection services) {
  services.AddHangfire(
      (provider, config) =>
          config
              .UseFilter(new ExampleFilter(
                  (IServiceScopeFactory)
                      provider.GetService(typeof(IServiceScopeFactory))))
              .UseActivator(new ExampleJobActivator(
                  (IServiceScopeFactory)
                      provider.GetService(typeof(IServiceScopeFactory))))
              .UseSqlServerStorage(
                  Configuration.GetConnectionString("HangfireDb"),
                  new SqlServerStorageOptions{
                      CommandBatchMaxTimeout = TimeSpan.FromMinutes(5),
                      UseRecommendedIsolationLevel = true,
                      DisableGlobalLocks = true}));

  services.AddHangfireServer();
}

This example is basic. Give it some thought and see if carrying some context data through to Hangfire could improve your codebase.