Creating a Quartz.NET hosted service with ASP.NET Core

In this blog, 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?

Quartz.NET is a full-featured, open-source job scheduling system that can be used from smallest apps to large-scale enterprise systems.

Quartz.NET has two main concepts:

  • job. These are the background tasks that you want to run on some sort of schedule.
  • scheduler. This is responsible for running jobs based on triggers, on a time-based schedule.

Installing Quartz.NET :

Quartz.NET is a .NET Standard 3.3.2 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.

PM > Install-Package Quartz -Version 3.3.2

Creating an IJob:

For the actual background work we are scheduling, we’re just going to use a “hello coder” 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 HelloCoderJob : IJob
{
    private readonly ILogger<HelloCoderJob> _logger;
    public HelloCoderJob(ILogger<HelloCoderJob> logger)
    {
        _logger = logger;
    }

    public Task Execute(IJobExecutionContext context)
    {
        _logger.LogInformation("Hello coder!");
        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 Hell0CodderJob(). 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 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. that fits into the required Quartz API, so we’re stuck only being able to create singleton jobs.

NOTE: 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 (HelloCoderJob 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<HelloCoderJob>();
    services.AddSingleton(new JobSchedule(
        jobType: typeof(HelloCoderJob),
        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-in StdSchedulerFactory, which handles scheduling and managing jobs
  • The HelloCoderdJob job itself
  • An instance of  JobSchedule for the HelloCoderdJob 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:

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 HelloCoderJob), 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.

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.

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. For example, if you need to use a scoped service in your HelloCoderJob, you could use something like the following:

ublic class HelloCoderJob : 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 coder!");
        }

        return Task.CompletedTask;
    }
}

This ensures a new scope is created every time the job runs, so you can retrieve (and dispose of) 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.

You Can Download the source code for this post. GitHub

I hope you guys understand how I can do this.  Let me know if you face any difficulties.

You can watch my previous blog here.

Happy Coding {;} ????

Submit a Comment

Your email address will not be published. Required fields are marked *

Subscribe

Select Categories