I have the following class
public class MyEmailService { public async Task<bool> SendAdminEmails() { ... } public async Task<bool> SendUserEmails() { ... } } public interface IMyEmailService { Task<bool> SendAdminEmails(); Task<bool> SendUserEmails(); }
I have installed the latest Quartz 2.4.1 Nuget package as I wanted a lightweight scheduler in my web app without a separate SQL Server database.
I need to schedule the methods
SendUserEmails
to run every week on Mondays 17:00,Tuesdays 17:00 & Wednesdays 17:00SendAdminEmails
to run every week on Thursdays 09:00, Fridays 9:00What code do I need to schedule these methods using Quartz in ASP.NET Core? I also need to know how to start Quartz in ASP.NET Core as all code samples on the internet still refer to previous versions of ASP.NET.
I can find a code sample for the previous version of ASP.NET but I don't know how to start Quartz in ASP.NET Core to start testing. Where do I put the JobScheduler.Start();
in ASP.NET Core?
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.
Quartz.Net is a . Net port of the popular Java job scheduling framework. It's an open source job scheduling system that can be used from smallest apps to large-scale enterprise systems.
Assumed tooling: Visual Studio 2017 RTM, .NET Core 1.1, .NET Core SDK 1.0, SQL Server Express 2016 LocalDB.
In web application .csproj:
<Project Sdk="Microsoft.NET.Sdk.Web"> <!-- .... existing contents .... --> <!-- add the following ItemGroup element, it adds required packages --> <ItemGroup> <PackageReference Include="Quartz" Version="3.0.0-alpha2" /> <PackageReference Include="Quartz.Serialization.Json" Version="3.0.0-alpha2" /> </ItemGroup> </Project>
In the Program
class (as scaffolded by Visual Studio by default):
public class Program { private static IScheduler _scheduler; // add this field public static void Main(string[] args) { var host = new WebHostBuilder() .UseKestrel() .UseContentRoot(Directory.GetCurrentDirectory()) .UseIISIntegration() .UseStartup<Startup>() .UseApplicationInsights() .Build(); StartScheduler(); // add this line host.Run(); } // add this method private static void StartScheduler() { var properties = new NameValueCollection { // json serialization is the one supported under .NET Core (binary isn't) ["quartz.serializer.type"] = "json", // the following setup of job store is just for example and it didn't change from v2 // according to your usage scenario though, you definitely need // the ADO.NET job store and not the RAMJobStore. ["quartz.jobStore.type"] = "Quartz.Impl.AdoJobStore.JobStoreTX, Quartz", ["quartz.jobStore.useProperties"] = "false", ["quartz.jobStore.dataSource"] = "default", ["quartz.jobStore.tablePrefix"] = "QRTZ_", ["quartz.jobStore.driverDelegateType"] = "Quartz.Impl.AdoJobStore.SqlServerDelegate, Quartz", ["quartz.dataSource.default.provider"] = "SqlServer-41", // SqlServer-41 is the new provider for .NET Core ["quartz.dataSource.default.connectionString"] = @"Server=(localdb)\MSSQLLocalDB;Database=Quartz;Integrated Security=true" }; var schedulerFactory = new StdSchedulerFactory(properties); _scheduler = schedulerFactory.GetScheduler().Result; _scheduler.Start().Wait(); var userEmailsJob = JobBuilder.Create<SendUserEmailsJob>() .WithIdentity("SendUserEmails") .Build(); var userEmailsTrigger = TriggerBuilder.Create() .WithIdentity("UserEmailsCron") .StartNow() .WithCronSchedule("0 0 17 ? * MON,TUE,WED") .Build(); _scheduler.ScheduleJob(userEmailsJob, userEmailsTrigger).Wait(); var adminEmailsJob = JobBuilder.Create<SendAdminEmailsJob>() .WithIdentity("SendAdminEmails") .Build(); var adminEmailsTrigger = TriggerBuilder.Create() .WithIdentity("AdminEmailsCron") .StartNow() .WithCronSchedule("0 0 9 ? * THU,FRI") .Build(); _scheduler.ScheduleJob(adminEmailsJob, adminEmailsTrigger).Wait(); } }
An example of a job class:
public class SendUserEmailsJob : IJob { public Task Execute(IJobExecutionContext context) { // an instance of email service can be obtained in different ways, // e.g. service locator, constructor injection (requires custom job factory) IMyEmailService emailService = new MyEmailService(); // delegate the actual work to email service return emailService.SendUserEmails(); } }
First, you have to use v3 of Quartz, as it targets .NET Core, according to this announcement.
Currently, only alpha versions of v3 packages are available on NuGet. It looks like the team put a lot of effort into releasing 2.5.0, which does not target .NET Core. Nevertheless, in their GitHub repo, the master
branch is already dedicated to v3, and basically, open issues for v3 release don't seem to be critical, mostly old wishlist items, IMHO. Since recent commit activity is quite low, I would expect v3 release in few months, or maybe half year - but no one knows.
If the web application is going to be hosted under IIS, you have to take into consideration recycling/unloading behavior of worker processes. The ASP.NET Core web app runs as a regular .NET Core process, separate from w3wp.exe - IIS only serves as a reverse proxy. Nevertheless, when an instance of w3wp.exe is recycled or unloaded, the related .NET Core app process is also signaled to exit (according to this).
Web application can also be self-hosted behind a non-IIS reverse proxy (e.g. NGINX), but I will assume that you do use IIS, and narrow my answer accordingly.
The problems that recycling/unloading introduces are explained well in the post referenced by @darin-dimitrov:
I can think of one justification of having those email jobs hosted in a web app, despite the problems listed above. It is decision to have only one kind of application model (ASP.NET). Such approach simplifies learning curve, deployment procedure, production monitoring, etc.
If you don't want to introduce backend microservices (which would be a good place to move the email jobs to), then it makes sense to overcome IIS recycling/unloading behaviors, and run Quartz inside a web app.
Or maybe you have other reasons.
In your scenario, status of job execution must be persisted out of process. Therefore, default RAMJobStore doesn't fit, and you have to use the ADO.NET Job Store.
Since you mentioned SQL Server in the question, I will provide example setup for SQL Server database.
I assume you use Visual Studio 2017 and latest/recent version of .NET Core tooling. Mine is .NET Core Runtime 1.1 and .NET Core SDK 1.0.
For DB setup example, I will use a database named Quartz
in SQL Server 2016 Express LocalDB. DB setup scripts can be found here.
First, add required package references to web application .csproj (or do it with NuGet package manager GUI in Visual Studio):
<Project Sdk="Microsoft.NET.Sdk.Web"> <!-- .... existing contents .... --> <!-- the following ItemGroup adds required packages --> <ItemGroup> <PackageReference Include="Quartz" Version="3.0.0-alpha2" /> <PackageReference Include="Quartz.Serialization.Json" Version="3.0.0-alpha2" /> </ItemGroup> </Project>
With the help of Migration Guide and the V3 Tutorial, we can figure out how to start and stop the scheduler. I prefer to encapsulate this in a separate class, let's name it QuartzStartup
.
using System; using System.Collections.Specialized; using System.Threading.Tasks; using Quartz; using Quartz.Impl; namespace WebApplication1 { // Responsible for starting and gracefully stopping the scheduler. public class QuartzStartup { private IScheduler _scheduler; // after Start, and until shutdown completes, references the scheduler object // starts the scheduler, defines the jobs and the triggers public void Start() { if (_scheduler != null) { throw new InvalidOperationException("Already started."); } var properties = new NameValueCollection { // json serialization is the one supported under .NET Core (binary isn't) ["quartz.serializer.type"] = "json", // the following setup of job store is just for example and it didn't change from v2 ["quartz.jobStore.type"] = "Quartz.Impl.AdoJobStore.JobStoreTX, Quartz", ["quartz.jobStore.useProperties"] = "false", ["quartz.jobStore.dataSource"] = "default", ["quartz.jobStore.tablePrefix"] = "QRTZ_", ["quartz.jobStore.driverDelegateType"] = "Quartz.Impl.AdoJobStore.SqlServerDelegate, Quartz", ["quartz.dataSource.default.provider"] = "SqlServer-41", // SqlServer-41 is the new provider for .NET Core ["quartz.dataSource.default.connectionString"] = @"Server=(localdb)\MSSQLLocalDB;Database=Quartz;Integrated Security=true" }; var schedulerFactory = new StdSchedulerFactory(properties); _scheduler = schedulerFactory.GetScheduler().Result; _scheduler.Start().Wait(); var userEmailsJob = JobBuilder.Create<SendUserEmailsJob>() .WithIdentity("SendUserEmails") .Build(); var userEmailsTrigger = TriggerBuilder.Create() .WithIdentity("UserEmailsCron") .StartNow() .WithCronSchedule("0 0 17 ? * MON,TUE,WED") .Build(); _scheduler.ScheduleJob(userEmailsJob, userEmailsTrigger).Wait(); var adminEmailsJob = JobBuilder.Create<SendAdminEmailsJob>() .WithIdentity("SendAdminEmails") .Build(); var adminEmailsTrigger = TriggerBuilder.Create() .WithIdentity("AdminEmailsCron") .StartNow() .WithCronSchedule("0 0 9 ? * THU,FRI") .Build(); _scheduler.ScheduleJob(adminEmailsJob, adminEmailsTrigger).Wait(); } // initiates shutdown of the scheduler, and waits until jobs exit gracefully (within allotted timeout) public void Stop() { if (_scheduler == null) { return; } // give running jobs 30 sec (for example) to stop gracefully if (_scheduler.Shutdown(waitForJobsToComplete: true).Wait(30000)) { _scheduler = null; } else { // jobs didn't exit in timely fashion - log a warning... } } } }
Note 1. In the above example, SendUserEmailsJob
and SendAdminEmailsJob
are classes that implement IJob
. The IJob
interface is slightly different from IMyEmailService
, because it returns void Task
and not Task<bool>
. Both job classes should get IMyEmailService
as a dependency (probably constructor injection).
Note 2. For a long-running job to be able to exit in timely fashion, in the IJob.Execute
method, it should observe the status of IJobExecutionContext.CancellationToken
. This may require change in IMyEmailService
interface, to make its methods receive CancellationToken
parameter:
public interface IMyEmailService { Task<bool> SendAdminEmails(CancellationToken cancellation); Task<bool> SendUserEmails(CancellationToken cancellation); }
In ASP.NET Core, application bootstrap code resides in class Program
, much like in console app. The Main
method is called to create web host, run it, and wait until it exits:
public class Program { public static void Main(string[] args) { var host = new WebHostBuilder() .UseKestrel() .UseContentRoot(Directory.GetCurrentDirectory()) .UseIISIntegration() .UseStartup<Startup>() .UseApplicationInsights() .Build(); host.Run(); } }
The simplest thing to do is just put a call to QuartzStartup.Start
right in the Main
method, much like as I did in TL;DR. But since we have to properly handle process shutdown as well, I prefer to hook both startup and shutdown code in a more consistent manner.
This line:
.UseStartup<Startup>()
refers to a class named Startup
, which is scaffolded when creating new ASP.NET Core Web Application project in Visual Studio. The Startup
class looks like this:
public class Startup { public Startup(IHostingEnvironment env) { // scaffolded code... } public IConfigurationRoot Configuration { get; } // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { // scaffolded code... } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) { // scaffolded code... } }
It is clear that a call to QuartzStartup.Start
should be inserted in one of methods in the Startup
class. The question is, where QuartzStartup.Stop
should be hooked.
In the legacy .NET Framework, ASP.NET provided IRegisteredObject
interface. According to this post, and the documentation, in ASP.NET Core it was replaced with IApplicationLifetime
. Bingo. An instance of IApplicationLifetime
can be injected into Startup.Configure
method through a parameter.
For consistency, I will hook both QuartzStartup.Start
and QuartzStartup.Stop
to IApplicationLifetime
:
public class Startup { // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure( IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory, IApplicationLifetime lifetime) // added this parameter { // the following 3 lines hook QuartzStartup into web host lifecycle var quartz = new QuartzStartup(); lifetime.ApplicationStarted.Register(quartz.Start); lifetime.ApplicationStopping.Register(quartz.Stop); // .... original scaffolded code here .... } // ....the rest of the scaffolded members .... }
Note that I have extended the signature of the Configure
method with an additional IApplicationLifetime
parameter. According to documentation, ApplicationStopping
will block until registered callbacks are completed.
I was able to observe expected behavior of IApplicationLifetime.ApplicationStopping
hook only on IIS, with the latest ASP.NET Core module installed. Both IIS Express (installed with Visual Studio 2017 Community RTM), and IIS with an outdated version of ASP.NET Core module didn't consistently invoke IApplicationLifetime.ApplicationStopping
. I believe it is because of this bug that was fixed.
You can install latest version of ASP.NET Core module from here. Follow the instructions in the "Installing the latest ASP.NET Core Module" section.
I also took a look at FluentScheduler, as it was proposed as an alternative library by @Brice Molesti. To my first impression, FluentScheduler is quite a simplistic and immature solution, compared to Quartz. For example, FluentScheduler doesn't provide such fundamental features as job status persistence and clustered execution.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With