Updated on
Quartz.NET is a job scheduling library for .NET built from three pieces: a job is the code that runs, a trigger decides when, and a scheduler holds them together and fires them.
Everything else is a variation on those three. A trigger can repeat on an interval or follow a CRON expression; a job can carry data; and the whole schedule can live in memory or in a database so it survives a restart.
This article makes use of Docker to run SQL Server server locally. Optionally a local install of SQL Server can be used.
VIDEO: Schedule Jobs With Quartz.NET in ASP.NET Core.
Let’s start with a look at the Quartz.NET main components.
What Is Quartz.NET in C#?
Quartz.NET is an open-source job scheduling library for .NET, ported from the Java Quartz scheduler. It runs code on a schedule inside our own application rather than relying on the operating system.
Three abstractions carry everything. An IJob implementation is the work. An ITrigger decides when that work fires. An IScheduler owns both and runs the loop.
Triggers are where the library earns its place. A simple trigger repeats on an interval a fixed number of times or forever; a CRON trigger takes a CRON expression, so “the first Monday of every month at 03:00” is a string rather than a calculation.
Job stores decide what survives a restart. In memory is the default and loses everything when the process ends; an ADO.NET store keeps jobs, triggers, and fire times in a database.
Jobs carry state through a JobDataMap, a dictionary attached to the job or the trigger and read back inside Execute().
For the choice between this library and its closest alternative, we have a dedicated article on how Quartz.NET compares with Hangfire.
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.
Quartz.NET plugs into that framework as a hosted service, which is the same extension point behind BackgroundService and its ExecuteAsync and StartAsync methods.
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.
How Do We Schedule Jobs With Quartz.NET?
Scheduling a job takes four steps, and they are the same four in a console app, a worker service, or an ASP.NET Core application.
Write a class implementing IJob. Its Execute() method receives an IJobExecutionContext and does the work.
Describe it as an IJobDetail through JobBuilder, giving it a name and a group so it can be found, paused, or replaced later.
Build an ITrigger through TriggerBuilder with the schedule attached: an interval, a repeat count, or a CRON expression.
Register Quartz with the host, get an IScheduler from ISchedulerFactory, and call ScheduleJob() with the pair.
Under the generic host, starting and stopping the scheduler is handled for us. Outside it, Start() and Shutdown() have to be called explicitly, and skipping Shutdown() is how jobs get killed mid-execution on exit.
The job class itself never holds state between runs. Quartz constructs a fresh instance for every firing, so anything a job needs has to arrive through the execution context rather than through a field.
The Quartz.NET tutorial is blunt about it: “Each (and every) time the scheduler executes the job, it creates a new instance of the class before calling its Execute(..) method.”
We’ll build this as a console application, using 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 host = 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, AddQuartzHostedService(), 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 = host.Services.GetRequiredService<ISchedulerFactory>(); var scheduler = await schedulerFactory.GetScheduler(); await scheduler.ScheduleJob(job, trigger); await host.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 host object, to start our application. Host.CreateDefaultBuilder().Build() returns an IHost, not a builder, which is why the variable is named host.
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.
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.UseNewtonsoftJsonSerializer();
});
});
Here, we update our existing AddQuartz() method to tell Quartz to use an SQL server with the UseSqlServer() method, passing the connection string to our docker database. Also, we call UseNewtonsoftJsonSerializer() to serialize any related data in the database as JSON.
The embedded video calls UseJsonSerializer(), which Quartz.NET has since marked obsolete. The code above uses its replacement, UseNewtonsoftJsonSerializer(), which does the same job.
With this, our jobs and trigger data will now be persisted in an SQL database so it’s available if the application ends or crashes unexpectedly.
If a job needs Entity Framework Core once it starts, the scoping question is the same one we cover in injecting a DbContext into a hosted service.
How Do We Read Typed Values From JobDataMap?
JobDataMap is a dictionary of values attached to a job or a trigger, and it exposes typed getters so we do not have to cast.
Inside Execute(), the map to read is context.MergedJobDataMap. The Quartz.NET tutorial describes it as a merger of the job’s map and the trigger’s, “with the values of the trigger overriding the same-named values in the job”. That is what makes the same job class reusable across several schedules.
GetString(), GetInt(), GetBoolean() and their siblings each read one key and convert. Get() returns the raw object when nothing typed fits.
Dates are the part that catches people out. A DateTime put in with UsingJobData() comes back cleanly from an in-memory store, but once a persistent store is involved, how the value was serialised decides what comes back.
Keeping job data small and primitive avoids the whole question. Pass identifiers, not objects, and let the job load what it needs from a database once it starts, where the types are ours to control.
| To read a… | Call | Notes |
|---|---|---|
| string | GetString(key) | |
| int | GetInt(key) | |
| long | GetLong(key) | |
| bool | GetBoolean(key) | |
| double | GetDouble(key) | |
| float | GetFloat(key) | |
DateTime | GetDateTime(key) | Round-trips only if the store keeps types; see below |
DateTimeOffset | GetDateTimeOffset(key) | Same caveat |
TimeSpan | GetTimeSpan(key) | Same caveat |
Guid | GetGuid(key) | Same caveat |
| anything else | Get(key) then cast |
Each getter has a TryGet… counterpart that returns false instead of throwing when the key is missing.
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 with Quartz.NET, 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, or compare it with the other ways to run background tasks in ASP.NET Core.
Tested with .NET 10.0.10 and Quartz.NET 3.19.1.
