There is a newer version of this post that uses the new Quartz.Extensions.Hosting package which implements most of the code in this post for you.
In this post I describe how to run Quartz.NET jobs using an ASP.NET Core hosted service. I show how to create a simple IJob
, a custom IJobFactory
, and a QuartzHostedService
that runs jobs while your application is running. I'll also touch on some of the issues to aware of, namely of using scoped services inside singleton classes.
Introduction - what is Quartz.NET?
As per their website:
Quartz.NET is a full-featured, open source job scheduling system that can be used from smallest apps to large scale enterprise systems.
It's an old staple of many ASP.NET developers, used as a way of running background tasks on a timer, in a reliable, clustered, way. Using Quartz.NET with ASP.NET Core is pretty similar - Quartz.NET supports .NET Standard 2.0, so you can easily use it in your applications.
Quartz.NET has two main concepts:
- A job. This is the background tasks that you want to run on some sort of schedule.
- A scheduler. This is responsible for running jobs based on triggers, on a time-based schedule.
ASP.NET Core has good support for running "background tasks" via way of hosted services. Hosted services are started when your ASP.NET Core app starts, and run in the background for the lifetime of the application. By creating a Quartz.NET hosted service, you can use a standard ASP.NET Core application for running your tasks in the background.
This sort of non-HTTP scenario is also possible with the "generic host", but for various reasons I generally don't use those at the moment. This should hopefully improve in ASP.NET Core 3.0 with the extra investment going into these non-HTTP scenarios.
While it's possible to create a "timed" background service, (that runs a tasks every 10 minutes, for example), Quartz.NET provides a far more robust solution. You can ensure tasks only run at specific times of the day (e.g. 2:30am), or only on specific days, or any combination by using a Cron trigger. It also allows you to run multiple instances of your application in a clustered fashion, so that only a single instance can run a given task at any one time.
In this post I'll show the basics of creating a Quartz.NET job and scheduling it to run on a timer in a hosted service.
Installing Quartz.NET
Quartz.NET is a .NET Standard 2.0 NuGet package, so it should be easy to install in your application. For this test I created an ASP.NET Core project and chose the Empty template. You can install the Quartz.NET package using dotnet add package Quartz
. If you view the .csproj for the project, it should look something like this:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp2.2</TargetFramework>
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Quartz" Version="3.0.7" />
</ItemGroup>
</Project>
Creating an IJob
For the actual background work we are scheduling, we're just going to use a "hello world" implementation that writes to an ILogger<>
(and hence to the console). You should implement the Quartz interface IJob
which contains a single asynchronous Execute()
method. Note that we're using dependency injection here to inject the logger into the constructor.
using Microsoft.Extensions.Logging;
using Quartz;
using System.Threading.Tasks;
[DisallowConcurrentExecution]
public class HelloWorldJob : IJob
{
private readonly ILogger<HelloWorldJob> _logger;
public HelloWorldJob(ILogger<HelloWorldJob> logger)
{
_logger = logger;
}
public Task Execute(IJobExecutionContext context)
{
_logger.LogInformation("Hello world!");
return Task.CompletedTask;
}
}
I also decorated the job with the [DisallowConcurrentExecution]
attribute. This attribute prevents Quartz.NET from trying to run the same job concurrently.
Creating an IJobFactory
Next, we need to tell Quartz how it should create instances of IJob
. By default, Quartz will try and "new-up" instances of the job using Activator.CreateInstance
, effectively calling new HelloWorldJob()
. Unfortunately, as we're using constructor injection, that won't work. Instead, we can provide a custom IJobFactory
that hooks into the ASP.NET Core dependency injection container (IServiceProvider
):
using Microsoft.Extensions.DependencyInjection;
using Quartz;
using Quartz.Spi;
using System;
public class SingletonJobFactory : IJobFactory
{
private readonly IServiceProvider _serviceProvider;
public SingletonJobFactory(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public IJob NewJob(TriggerFiredBundle bundle, IScheduler scheduler)
{
return _serviceProvider.GetRequiredService(bundle.JobDetail.JobType) as IJob;
}
public void ReturnJob(IJob job) { }
}
This factory takes an IServiceProvider
in the constructor, and implements the IJobFactory
interface. The important method is the NewJob()
method, in which the factory has to return the IJob
requested by the Quartz scheduler. In this implementation we delegate directly to the IServiceProvider
, and let the DI container find the required instance. The cast to IJob
at the end is required because the non-generic version of GetRequiredService
returns an object
.
The ReturnJob
method is where the scheduler tries to return (i.e. destroy) a job that was created by the factory. Unfortunately, there's no mechanism for doing so with the built-in IServiceProvider
. We can't create a new IScopeService
that fits into the required Quartz API, so we're stuck only being able to create singleton jobs.
This is important. With the above implementation, it is only safe to create
IJob
implementations that are Singletons (or transient).
Configuring the Job
I'm only showing a single IJob
implementation here, but we want the Quartz hosted service to be a generic implementation that works for any number of jobs. To help with that, we create a simple DTO called JobSchedule
that we'll use to define the timer schedule for a given job type:
using System;
public class JobSchedule
{
public JobSchedule(Type jobType, string cronExpression)
{
JobType = jobType;
CronExpression = cronExpression;
}
public Type JobType { get; }
public string CronExpression { get; }
}
The JobType
is the .NET type of the job (HelloWorldJob
for our example), and CronExpression
is a Quartz.NET Cron expression. Cron expressions allow complex timer scheduling so you can set rules like "fire every half hour between the hours of 8 am and 10 am, on the 5th and 20th of every month". Just be sure to check the documentation for examples as not all Cron expressions used by different systems are interchangeable.
We'll add the job to DI and configure its schedule in Startup.ConfigureServices()
:
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Quartz;
using Quartz.Impl;
using Quartz.Spi;
public void ConfigureServices(IServiceCollection services)
{
// Add Quartz services
services.AddSingleton<IJobFactory, SingletonJobFactory>();
services.AddSingleton<ISchedulerFactory, StdSchedulerFactory>();
// Add our job
services.AddSingleton<HelloWorldJob>();
services.AddSingleton(new JobSchedule(
jobType: typeof(HelloWorldJob),
cronExpression: "0/5 * * * * ?")); // run every 5 seconds
}
This code adds four things as singletons to the DI container:
- The
SingletonJobFactory
shown earlier, used for creating the job instances. - An implementation of
ISchedulerFactory
, the built-inStdSchedulerFactory
, which handles scheduling and managing jobs - The
HelloWorldJob
job itself - An instance of
JobSchedule
for theHelloWorldJob
with a Cron expression to run every 5 seconds.
There's only one piece missing now that brings them all together, the QuartzHostedService
.
Creating the QuartzHostedService
The QuartzHostedService
is an implementation of IHostedService
that sets up the Quartz scheduler, and starts it running in the background. Due to the design of Quartz, we can implement IHostedService
directly, instead of the more common approach of deriving from the base BackgroundService
class. The full code for the service is listed below, and I'll discuss it afterwards.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Quartz;
using Quartz.Spi;
public class QuartzHostedService : IHostedService
{
private readonly ISchedulerFactory _schedulerFactory;
private readonly IJobFactory _jobFactory;
private readonly IEnumerable<JobSchedule> _jobSchedules;
public QuartzHostedService(
ISchedulerFactory schedulerFactory,
IJobFactory jobFactory,
IEnumerable<JobSchedule> jobSchedules)
{
_schedulerFactory = schedulerFactory;
_jobSchedules = jobSchedules;
_jobFactory = jobFactory;
}
public IScheduler Scheduler { get; set; }
public async Task StartAsync(CancellationToken cancellationToken)
{
Scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
Scheduler.JobFactory = _jobFactory;
foreach (var jobSchedule in _jobSchedules)
{
var job = CreateJob(jobSchedule);
var trigger = CreateTrigger(jobSchedule);
await Scheduler.ScheduleJob(job, trigger, cancellationToken);
}
await Scheduler.Start(cancellationToken);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
await Scheduler?.Shutdown(cancellationToken);
}
private static IJobDetail CreateJob(JobSchedule schedule)
{
var jobType = schedule.JobType;
return JobBuilder
.Create(jobType)
.WithIdentity(jobType.FullName)
.WithDescription(jobType.Name)
.Build();
}
private static ITrigger CreateTrigger(JobSchedule schedule)
{
return TriggerBuilder
.Create()
.WithIdentity($"{schedule.JobType.FullName}.trigger")
.WithCronSchedule(schedule.CronExpression)
.WithDescription(schedule.CronExpression)
.Build();
}
}
The QuartzHostedService
has three dependencies: the ISchedulerFactory
and IJobFactory
we configured in Startup
, and an IEnumerable<JobSchedule>
. We only added a single JobSchedule
to the DI container (for the HelloWorldJob
), but if you register more job schedules with the DI container they'll all be injected here.
StartAsync
is called when the application starts up and is where we configure Quartz. We start by creating an instance of IScheduler
, assigning it to a property for use later, and setting the JobFactory
for the scheduler to the injected instance:
public async Task StartAsync(CancellationToken cancellationToken)
{
Scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
Scheduler.JobFactory = _jobFactory;
// ...
}
Next, we loop through the injected job schedules, and create a Quartz IJobDetail
and ITrigger
for each one using the CreateJob
and CreateTrigger
helper methods at the end of the class. If you don't like how this part works, or need more control over the configuration, you can easily customise it by extending the JobSchedule
DTO as you see fit.
public async Task StartAsync(CancellationToken cancellationToken)
{
// ...
foreach (var jobSchedule in _jobSchedules)
{
var job = CreateJob(jobSchedule);
var trigger = CreateTrigger(jobSchedule);
await Scheduler.ScheduleJob(job, trigger, cancellationToken);
}
// ...
}
private static IJobDetail CreateJob(JobSchedule schedule)
{
var jobType = schedule.JobType;
return JobBuilder
.Create(jobType)
.WithIdentity(jobType.FullName)
.WithDescription(jobType.Name)
.Build();
}
private static ITrigger CreateTrigger(JobSchedule schedule)
{
return TriggerBuilder
.Create()
.WithIdentity($"{schedule.JobType.FullName}.trigger")
.WithCronSchedule(schedule.CronExpression)
.WithDescription(schedule.CronExpression)
.Build();
}
Finally, once all the jobs are scheduled, you call Scheduler.Start()
to actually start the Quartz.NET scheduler processing in the background. When the app shuts down, the framework will call StopAsync()
, at which point you can call Scheduler.Stop()
to safely shut down the scheduler process.
public async Task StopAsync(CancellationToken cancellationToken)
{
await Scheduler?.Shutdown(cancellationToken);
}
You can register the hosted service using the AddHostedService()
extension method in Startup.ConfigureServices
:
public void ConfigureServices(IServiceCollection services)
{
// ...
services.AddHostedService<QuartzHostedService>();
}
If you run the application, you should see the background task running every 5 seconds and writing to the Console (or wherever you have logging configured)
Using scoped services in jobs
There's one big problem with the implementation as described in this post: you can only create Singleton or Transient jobs. That means you can't use any dependencies that are registered as Scoped services. For example, you can't inject an EF Core DatabaseContext
into your IJob
implementation, as you'll have a captive dependency problem.
Working around this isn't a big issue: you can inject an IServiceProvider
and create your own scope, similar to the solution for a similar problem in a previous post. For example, if you need to use a scoped service in your HelloWorldJob
, you could use something like the following:
public class HelloWorldJob : IJob
{
// Inject the DI provider
private readonly IServiceProvider _provider;
public HelloWorldJob( IServiceProvider provider)
{
_provider = provider;
}
public Task Execute(IJobExecutionContext context)
{
// Create a new scope
using(var scope = _provider.CreateScope())
{
// Resolve the Scoped service
var service = scope.ServiceProvider.GetService<IScopedService>();
_logger.LogInformation("Hello world!");
}
return Task.CompletedTask;
}
}
This ensures a new scope is created every time the job runs, so you can retrieve (and dispose) scoped services inside the IJob
. Unfortunately things do get a little messy. In the next post I'll show a variation on this approach that is a little cleaner.
Summary
In this post I introduced Quartz.NET and showed how you could use it to schedule background jobs to run in ASP.NET Core using IHostedService
. The example shown in this post is best for singleton or transient jobs, which isn't ideal, as consuming scoped services is clumsy. In the next post, I'll show a variation on this approach that makes using scoped services easier.