How to write your own cron Job scheduler in ASP.NET Core (like Quartz, Hangfire, ...)

In this blog post we will discover how to write your own small task scheduler / job server with cron notation in ASP.NET Core. You might know similar approaches under the name of Quartz or Hangfire.

With the help of BackgroundService we will build our own, lightweight version of it.


Disclaimer: Obviously, the following code is meant for educational purposes to understand how to implement a very naive version of a job scheduler in C# with ASP.NET Core. If you need a full fledge and well-tested version, please stick to Quartz or Hangfire.


The goal

Before I go into details, I want to outline what you will see in this blog post. I want to create a background worker in ASP.NET Core that run recurringly via the cron notation.

Cron notation, also known as a cron expression, is a syntax used to define time and date-based schedules in various operating systems and applications, including Unix-based systems and web-based cron services.

A cron expression consists of five or six fields that specify the time and date when a particular task or command should be executed. The fields are separated by whitespace and represent the following values:

*    *    *    *    *
-    -    -    -    -
|    |    |    |    |
|    |    |    |    +----- day of the week (0 - 6) (Sunday=0)
|    |    |    +---------- month (1 - 12)
|    |    +--------------- day of the month (1 - 31)
|    +-------------------- hour (0 - 23)
+------------------------- minute (0 - 59)

Each field can be a specific value or a range of values, and commas can separate multiple values. In addition, special characters such as asterisks (*) and question marks (?) can represent all possible values or indicate a specific field that should be ignored.

For example, the cron expression "0 0 1 * *" would execute a command at midnight on the first day of every month, while "0 0 * * 0" would execute a command at midnight every Sunday. The smallest resolution cron allows is on a minute base. Keep this in mind! You can not schedule a job every second or every 30 seconds.

I also want to use the DI-container that comes with ASP.NET Core itself.

A typical usage of such a thing would be reporting. Every day at midnight, for example, I like to generate a PDF report that will be sent out via E-Mail. We can also define it monthly, or bi-weekly. That is the cool thing with the cron notation, that we have the flexibility to do so.

Also, another big pillar: I want to respect the SOLID principles. Especially the S aka Single responsibility principle (here a refresher: "SOLID Principles in C#". With this we can easily extent the mechanism without compromising readability or testability.

BackgroundService

The first question we should answer before coding is "What is a BackgroundService?". A BackgroundService is a long-running service running in the background of an ASP.NET Core application. It is typically used to perform database maintenance, sending emails, or processing messages from a message queue.

A BackgroundService is implemented by creating a class that inherits from the BackgroundService base class and overrides the ExecuteAsync method. The ExecuteAsync method is where the code to perform the long-running task should be written. The BackgroundService base class takes care of starting and stopping the service and handling any exceptions that may occur during the execution of the service.

BackgroundServices can be registered with the ASP.NET Core dependency injection system, allowing them to be easily integrated into the application. They can also be hosted in various ways, including as a standalone console application or as part of an ASP.NET Core web application.

BackgroundServices are registered singleton! And that is vital to understand because it isn't called background without reason. As such, you don't have any HttpContext around. Also, the service itself is registered as a singleton. So retrieving scoped services as a constructor dependency does not work out of the box.

Usage

I want to have a very easy entry for the user to register services. Something like this:

builder.Services.AddCronJob<CronJob>("* * * * *");
builder.Services.AddCronJob<AnotherCronJob>("*/2 * * * *");

The only parameter we have is the cron expression. Now for the sake of simplicity, the cron expression will always be evaluated to UTC dates. In a real-world example, you might want to introduce time zone information as well.

How does the AddCronJob function look like?

public static IServiceCollection AddCronJob<T>(this IServiceCollection services, string cronExpression)
    where T : class, ICronJob
{
    var cron = CrontabSchedule.TryParse(cronExpression)
               ?? throw new ArgumentException("Invalid cron expression", nameof(cronExpression));

    var entry = new CronRegistryEntry(typeof(T), cron);

    services.AddHostedService<CronScheduler>();
    services.TryAddSingleton<T>();
    services.AddSingleton(entry);

    return services;
}

First, we can see it is an extension method. It wants a T that inherits from ICronJob. So every job that we want to execute has to implement said interface. But the interface is very slim:

public interface ICronJob
{
    Task Run(CancellationToken token = default);
}

Back to our extension method. We have a CrontabSchedule.TryParse(cronExpression) here. This method comes from the NCronTab library that does all the heavy lifting in terms of cron parsing for us. If we can't parse the expression, we throw an exception.

The next line (var entry = new CronRegistryEntry(typeof(T), cron)) creates a new object. The object is defined as follows:

public sealed record CronRegistryEntry(Type Type, CrontabSchedule CrontabSchedule);

Basically, we are creating an entry in a registry we can pick up later. So each of those entries is one job linked to a cron expression. We are separating all those things so that our ICronJob itself doesn't have to know anything about a scheduler or a cron notation in the first place. It also keeps our system extendable. I will make this more clear in the outlook part.

Now the next three lines might be confusing:

services.AddHostedService<CronScheduler>();
services.TryAddSingleton<T>();
services.AddSingleton(entry);

Let's start with the second line. Why do I use TryAddSingleton. The TryAddXXX methods will check if there is already a registration for the given type. If so, it does not add another one to the collection. That is important because we only want to have one "type-defintion" of T around. Keep in mind that we can call AddCronJob with the same T but different cron schedules multiple times.

Also, it has to be a singleton because the AddHostedService will register CronScheduler as a singleton. They have to be the same lifetime! Now, why didn't I use something like TryAddHostedService<CronScheduler>? Well, there isn't such a method and AddHostedService itself uses TryAddSingleton internally - so everything I said earlier applies also here.

In the end, we are just adding one entry to our registry - you will see what that looks like in a second.

Scheduling the tasks

Now we are coming to the heavy lifting part. I explain very briefly how it works and show the code afterward. The code is annotated with comments to guide you through.

  1. Get all registry entries as a constructor parameter. A bit more background here: If you call AddXXX<IMyType>() multiple times, you can retrieve all registrations in the constructor if you ask for an IEnumerable<IMyType>. We can use this behavior to build up our registry where all types and their respective cron expression are listed.
  2. Create a timer with a smaller resolution than one minute. Remember that cron expression's smallest unit is a minute, so as long as we are under a minute, we don't miss any event.
  3. On every tick:
    1. Run jobs that fulfill the cron expression
    2. Get the jobs to run for the next tick
public sealed class CronScheduler : BackgroundService
{
    private readonly IServiceProvider _serviceProvider;
    private readonly IReadOnlyCollection<CronRegistryEntry> _cronJobs;

    public CronScheduler(
        IServiceProvider serviceProvider,
        IEnumerable<CronRegistryEntry> cronJobs)
    {
        // Use the container
        _serviceProvider = serviceProvider;
        _cronJobs = cronJobs.ToList();
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // Create a timer that has a resolution less than 60 seconds
        // Because cron has a resolution of a minute
        // So everything under will work
        using var tickTimer = new PeriodicTimer(TimeSpan.FromSeconds(30));

        // Create a map of the next upcoming entries
        var runMap = new Dictionary<DateTime, List<Type>>();
        while (await tickTimer.WaitForNextTickAsync(stoppingToken))
        {
            // Get UTC Now with minute resolution (remove microseconds and seconds)
            var now = UtcNowMinutePrecision();

            // Run jobs that are in the map
            RunActiveJobs(runMap, now, stoppingToken);
            
            // Get the next run for the upcoming tick
            runMap = GetJobRuns();
        }
    }

    private void RunActiveJobs(IReadOnlyDictionary<DateTime, List<Type>> runMap, DateTime now, CancellationToken stoppingToken)
    {
        if (!runMap.TryGetValue(now, out var currentRuns))
        {
            return;
        }

        foreach (var run in currentRuns)
        {
            // We are sure (thanks to our extension method)
            // that the service is of type ICronJob
            var job = (ICronJob)_serviceProvider.GetRequiredService(run);
            
            // We don't want to await jobs explicitly because that
            // could interfere with other job runs
            job.Run(stoppingToken);
        }
    }

    private Dictionary<DateTime, List<Type>> GetJobRuns()
    {
        var runMap = new Dictionary<DateTime, List<Type>>();
        foreach (var cron in _cronJobs)
        {
            var utcNow = DateTime.UtcNow;
            var runDates = cron.CrontabSchedule.GetNextOccurrences(utcNow, utcNow.AddMinutes(1));
            if (runDates is not null)
            {
                AddJobRuns(runMap, runDates, cron);
            }
        }

        return runMap;
    }

    private static void AddJobRuns(IDictionary<DateTime, List<Type>> runMap, IEnumerable<DateTime> runDates, CronRegistryEntry cron)
    {
        foreach (var runDate in runDates)
        {
            if (runMap.TryGetValue(runDate, out var value))
            {
                value.Add(cron.Type);
            }
            else
            {
                runMap[runDate] = new List<Type> { cron.Type };
            }
        }
    }

    private static DateTime UtcNowMinutePrecision()
    {
        var now = DateTime.UtcNow;
        return new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0);
    }
}

Puuuhhhhhhh - that was a lot of work. But now the cool part, implementing a ICronJob is super easy.

Implementing an example

A super trivial job could look like this:

public class CronJob : ICronJob
{
    private readonly ILogger<CronJob> _logger;

    public CronJob(ILogger<CronJob> logger)
    {
        _logger = logger;
    }
    public Task Run(CancellationToken token = default)
    {
        _logger.LogInformation("Hello from {name} at: {time}", nameof(CronJob), DateTime.UtcNow.ToShortTimeString());

        return Task.CompletedTask;
    }
}

As you can see, there is even a service from the DI container involved. If we run a program with the given cron notation at the beginning and such a job, we get the following output:

info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5163
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
      Content root path: /Users/stgi/repos/BlogExamples/CronBackgroundWorker
info: CronBackgroundWorker.CronJob[0]
      Hello from CronJob at: 19:37
info: CronBackgroundWorker.CronJob[0]
      Hello from CronJob at: 19:38
Hello from AnotherCronJob at: 19:38
info: CronBackgroundWorker.CronJob[0]
      Hello from CronJob at: 19:39
Hello from AnotherCronJob at: 19:40
info: CronBackgroundWorker.CronJob[0]
      Hello from CronJob at: 19:40
info: CronBackgroundWorker.CronJob[0]
      Hello from CronJob at: 19:41
info: CronBackgroundWorker.CronJob[0]
      Hello from CronJob at: 19:42
Hello from AnotherCronJob at: 19:42

The implementation doesn't know anything about a scheduler or a cron expression - as it should be. By the way, the whole code is linked at the end of this blog post article. So if you want to have a running example, just head over to the end.

Getting scoped Services

There is a fundamental "issue" we have to overcome. All of our services are registered as singleton (as we don't have a scope in a BackgroundService). But what if we want to use a scoped service inside our cron job? A typical example would be Entity Frameworks DbContext. For that you can inject the IServiceProvider directly and create a scope.

public class JobWithScopedDependency : ICronJob
{
    private readonly IServiceProvider _serviceProvider;
    public JobWithScopedDependency(IServiceProvider services)
    {
        _serviceProvider = services;
    }

    public Task Run(CancellationToken token = default)
    {
        using var scope = _serviceProvider.CreateScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>();
        return Task.CompletedTask;
    }
}

Outlook

I talked about modularity and maintainability. Just imagine you have some job that mutates data. Obviously, you don't want to have multiple instances running at the same time. So we could introduce either an Attribute on the implementation or add another parameter to our extension method. In any case, the job itself should not know about that. We might want to split the scheduler into two things with this new requirement. Something that actually schedules things and something that does the execution. The latter one would be responsible for prohibiting multiple runs if it sees that there is still an instance running.

Conclusion

I hope I could show you how a job scheduler (simplified) works and how we can apply SOLID principles while doing so.

Resources

  • Source code to this blog post: here
  • All my sample code is hosted in this repository: here
33
An error has occurred. This application may no longer respond until reloaded. Reload x