Recurring, background tasks are widespread and very common when building applications. These tasks can be long-running or repetitive and we don’t want to run them in the foreground, as they could affect the user’s experience of the application. So instead we must schedule these jobs to run in the background somewhere.  To achieve this in .NET, we can use the Quartz.NET library to manage the creation and scheduling of these jobs.

This article makes use of Docker to run SQL Server server locally. Optionally a local install of SQL Server can be used.

To download the source code for this article, you can visit our GitHub repository.

Let’s start with a look at the Quartz.NET main components.

What is Quartz.NET?

Quartz.NET, very simply put, is a job scheduling system for .NET applications. Very similar to a library such as Hangfire, Quartz.NET allows us to easily create and schedule jobs to run on a configured trigger

Unlike Hangfire, Quartz.NET provides more features to deal with recurring jobs, such as CRON triggers or calendars, the latter of which allows us to define schedules for jobs that include rules based on calendar events such as holidays, etc. So, when we need to create advanced job triggers, Quartz.NET is the best choice.

Why do we want to schedule jobs?

Any job that isn’t an arbitrarily simple task is going to require some processing power that could affect our application performance. This is not ideal for users of our application as it can affect their user experience and interactions.

For example, if we have a file upload function in our application that allows users to upload a profile picture, we don’t want the user to wait around and be unable to interact with the application. Instead, we can schedule a job to upload this in the background, and the user can continue to use the application without any performance implications.

There are many other use cases for job scheduling, but the important thing to understand is these jobs can be long-running, and the best practice is to run them in the background, usually initiated by some sort of trigger. Now we understand what jobs are and why we’d want to schedule them, let’s look at the main components of the Quartz.NET library.

How to Create Jobs

Whenever we want to define a job in Quartz.NET, we need to implement the IJob interface. This interface contains one method:

Task Execute(IJobExecutionContext context);

This method is invoked whenever the job is triggered. The IJobExecutionContext parameter contains information about the environment, such as the details of the job and the trigger that executed the job. It also contains a  JobDataMap property, which can be used to store any number of serialized objects that may be required by the job instance when it executes. This object is a custom implementation of the Dictionary class.

Simply implementing the IJob interface is not enough.

We need to define an IJobDetail object that is tied to our job class:

IJobDetail job = JobBuilder.Create<MyJob>()
    .WithIdentity(name: "MyJob", group: "JobGroup")
    .Build();

Here, we give the job some identifiers, such as a name and what group it’s associated with.

Now we understand the basics of a job, let’s look at how we can trigger it.

Job Triggers

When it comes to triggering a job, we use the TriggerBuilder class to define and create an ITrigger object that can be later added to the scheduler.

The simplest form of a trigger to create is a SimpleTrigger:

ITrigger trigger = TriggerBuilder.Create()
    .WithIdentity(name: "SimpleTrigger", group: "TriggerGroup")
    .WithSimpleSchedule(s => s
        .WithRepeatCount(10)
        .WithInterval(TimeSpan.FromSeconds(10)))
    .Build();

First, we start by giving the trigger an identity (name and group) just like we did for the job. 

Next, we call the WithSimpleSchedule() method, which is where we define the actual schedule for the trigger. We configure the trigger to execute every 10 seconds, for a total of 10 times.

If we need more flexibility when it comes to triggering a job, we can use a CronTrigger. This allows us to define schedules such as “every Wednesday at 2 PM” or “every other day between 9 AM and 5 PM”.

Now that we know how to define a job and create a trigger for it, we need to schedule it.

Schedule Jobs

The final core component of the Quartz.NET library is the SchedulerFactory class and IScheduler interface. The scheduler manages the lifecycle of jobs and triggers and is responsible for scheduling related operations, such as pausing triggers, etc. We gain access to an IScheduler instance by retrieving it from the SchedulerFactory:

var schedulerFactory = SchedulerBuilder.Create().Build();
var scheduler = await schedulerFactory.GetScheduler();

With our scheduler instance, we can now schedule our jobs with their associated triggers:

await scheduler.ScheduleJob(job, trigger);

If we aren’t making use of the Microsoft Hosting framework, we must explicitly start the scheduler:

await scheduler.Start();

Finally, to clean up any resources, we use the Shutdown() method:

await scheduler.Shutdown();

If we are taking advantage of the Microsoft Hosting framework, which allows us to register services in the dependency injection framework, we do not need to explicitly call the Start() and Shutdown() methods, as these are handled by the Quartz.NET library. Furthermore, we can avoid instantiating a SchedulerFactory from the SchedulerBuilder class, as it will be available to us in the dependency injection framework.

Job Stores

Quartz.NET needs somewhere to store all the scheduler-related data such as jobs, triggers, job data, etc. By default, all this data is stored in memory by using the RAMJobStore job store. This option is fine for simple applications and requires very little configuration. However, as it’s stored in memory, when the application ends or crashes, all this scheduling information is lost, which we don’t want.

Fortunately, Quartz.NET has abstracted the job stores behind the IJobStore interface, and provides us with an alternative for storing information, AdoJobStore. This utilizes the ADO.NET library, which allows our .NET applications to talk with databases. SQL Server, PostgreSQL, and MySQL are just a few of the supported database providers.

This has the benefit of retaining our scheduler information whenever the application ends or crashes, so it can pick back up from where it left off.

Now we understand the core components of the Quartz.NET library, let’s look at how we schedule background jobs by creating a .NET application.

Schedule Jobs With Quartz.NET

For purposes of demonstrating the Quartz.NET library, we’ll keep things simple and create a console application with background jobs. We can use Visual Studio or the dotnet new console command.

Next, we use the NuGet Package Manager to add the Quartz, Quartz.Extensions.Hosting and Microsoft.Extensions.Hosting packages. The final two packages allow us to use the service framework and register the Quartz services.

Now that we have our application, let’s start by creating a job.

Create Jobs

Let’s create a BackgroundJob class:

public class BackgroundJob : IJob
{
    public async Task Execute(IJobExecutionContext context)
    {
        await Console.Out.WriteLineAsync("Executing background job");
    }
}

We implement the IJob interface, and in the Execute() method, simply write to the console to begin.

Later on, we’ll look at more advanced scenarios for our job, but this will do for now.

The final piece, in the Program class, is to create our job as an IJobDetail which can later be added to the scheduler:

var job = JobBuilder.Create<BackgroundJob>()
    .WithIdentity(name: "BackgroundJob", group: "JobGroup")
    .Build();

We provide the job with a name and group so we can identify it later.

Next up, we need a trigger for our job.

Add a Trigger for Job

Earlier we looked at the SimpleTrigger, so let’s use this to create our trigger:

var trigger = TriggerBuilder.Create()
    .WithIdentity(name: "RepeatingTrigger", group: "TriggerGroup")
    .WithSimpleSchedule(o => o
        .RepeatForever()
        .WithIntervalInSeconds(5))
    .Build();

Here, we define an infinitely repeating trigger with the RepeatForever() method that triggers every 5 seconds.

Now we have our trigger and job defined, let’s configure the scheduler and see our application in action.

Configure Scheduler

To start, we need to register the Quartz services, so we can retrieve an ISchedulerFactory from the service collection:

var builder = Host.CreateDefaultBuilder()
    .ConfigureServices((cxt, services) =>
    {
        services.AddQuartz();
        services.AddQuartzHostedService(opt =>
        {
            opt.WaitForJobsToComplete = true;
        });
    }).Build();

First, we register the services with the AddQuartz() extension method. The second method, AddQuartzHostedSerivce(), configures Quartz.NET with the worker service framework in .NET, which allows us to run background tasks. We configure this hosted service to wait for all jobs to be complete before exiting. For now, we will use the in-memory job store to keep things simple.

Now, we can retrieve an instance of ISchedulerFactory and add our job and trigger to the scheduler:

var schedulerFactory = builder.Services.GetRequiredService<ISchedulerFactory>();
var scheduler = await schedulerFactory.GetScheduler();

await scheduler.ScheduleJob(job, trigger);
await builder.RunAsync()

We use the ScheduleJob() method, passing our job and trigger objects previously created. As we didn’t define a start time for our trigger, this will begin to execute our job immediately, with our configured interval.

Finally, we call the RunAsync() method on the builder host object, to start our application.

Running our application, we will see our background job writing to the console every 5 seconds:

Executing background job
Executing background job
Executing background job
...

Currently, our background job is pretty basic. Next, let’s look at passing data to our job using the JobDataMap property.

Pass data to Jobs

To pass data to our job, we use the UsingJobData() method with defining our job:

var job = JobBuilder.Create<BackgroundJob>()
    .WithIdentity(name: "BackgroundJob", group: "JobGroup")
    .UsingJobData("ConsoleOutput", "Executing background job using JobDataMap")
    .UsingJobData("UseJobDataMapConsoleOutput", true)
    .Build();

Here, we add two entries to the JobDataMap dictionary, a string value, and a boolean.

Let’s look at how we retrieve and use these in our BackgroundJob class:

public async Task Execute(IJobExecutionContext context)
{
    var jobDataMap = context.MergedJobDataMap;
    var useJobDataMapConsoleOutput = jobDataMap.GetBoolean("UseJobDataMapConsoleOutput");

    if (useJobDataMapConsoleOutput)
    {
        var consoleOutput = jobDataMap.GetString("ConsoleOutput");
        await Console.Out.WriteLineAsync(consoleOutput);
    }
    else
    { 
       await Console.Out.WriteLineAsync("Executing background job without JobDataMap");
    }
}

We start by using the context object to retrieve the MergedJobDataMap object. This provides us access to the dictionary with some helper methods to get different data types. The MergedJobDataMap is the preferred property to use, as it merges any data added to the job or trigger.

We use the GetBoolean() method to get the boolean value for UseJobDataMapConsoleOutput that we configured earlier. If this is true, we retrieve the ConsoleOutput entry from the JobDataMap with the GetString() method, and write that to the console, otherwise, we use the previous console message we created.

Let’s run our application again, where we should now see the output from the JobDataMap:

Executing background job using JobDataMap
Executing background job using JobDataMap
Executing background job using JobDataMap
...

Store Jobs Data in Database

So far we’ve stored any job and trigger-related data in memory, which is not the best practice. So now we’ll look at storing our data in a database. Before we do this, we need two new NuGet packages: Quartz.Serialization.Json and Microsoft.Data.SqlClient.

Also, let’s run a SQL server locally in Docker, using the official Linux image:

docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=<Password>" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest

Be sure to replace <Password>with a sensible value.

To view this content, you must be a member of Code's Patreon at $1 or more

Next, connect to the server with the username sa and the password used in the docker run command. We’ll create a new database called Quartz. The final step here is to create the database from the scripts provided in the Quartz.NET GitHub repository.

With these packages installed and our database created, let’s configure Quartz to use the SQL database for storing data:

services.AddQuartz(opt =>
{
    opt.UsePersistentStore(s =>
    {
        s.UseSqlServer("<CONNECTION_STRING>");
        s.UseJsonSerializer();
    });
});

Here, we update our existing AddQuartz() method to tell Quartz to use SQL server with the UseSqlServer() method, passing the connection string to our docker database. Also, we call UseJsonSerializer()to serialize any related data in the database as JSON.

With this, our jobs and trigger data will now be persisted in a SQL database so it’s available if the application ends or crashes unexpectedly.

Conclusion

Running tasks in the background is a useful and important feature to implement in our applications for long-running tasks. In this article, we looked at how we could create and schedule jobs using the Quartz.NET library, as well as looked at some of the more advanced features of Quartz.NET, such as configuring a persistent datastore.

If you enjoyed this article and want to learn more about running background tasks in .NET, check out our similar article Long-Running Tasks in a Monolith ASP.NET Core Application.

This content is available exclusively to members of Code's Patreon at $0 or more.