I am a big fan of automating processes. So, when I want to take some time off, I don’t need to train somebody to run processes. Processes are running smoothly until … the Windows Task Scheduler decides for whatever reason that it just doesn’t launch your task for no other reason then who knows why! And if this job runs only every quarter, chances are that you will just not figure that it didn’t run until somebody wakes up!
Also, when you replace a computer (or a VM) on which tasks are running for a new one, you often have to recreate all the tasks one-by-one. I have tried to export them to re-import on the newer server but there is always a little something preventing from running as it should.
Or maybe you don’t want to go as far as replacing the Window Task Scheduler, you may just want to schedule some tasks inside your own application.
For your scheduling needs, there is a free component out there called Quartz.Net.
The downloadable code
This month’s downloadable demo solution contains both VB and C# projects. The solution was created using Visual Studio 2017 but should also work in most older versions as well.
The projects of this solution will use the free Quartz Enterprise Scheduler .Net library.
Why Quartz.Net?
Because it is free to use (Apache License V2). And because it is surely the most known library of that kind. Like many other libraries, Quartz existed in Java before it was ported to .Net. And also, at the time of writing, it looks like the library is really alive (latest update about a month ago).
There are also other libraries doing much of the same thing. Scott Hanselman listed some a few years ago. And part of that list, one is even offering dedicated support for an annual fee (some companies are just allergic to free stuff!) – otherwise, it is completely free to use.
Adding the Quartz.Net library to the solution
The easiest way to integrate the Quartz.Net library into your project is by adding it through the “Manage NuGet Packages for Solution” as shown on figure 1.
Figure 1: Adding the library to your projects
Initializing the scheduler
Three parts need to be implemented in order to schedule jobs in your applications. Namely we are talking about the scheduler, the jobs and the triggers. Let starts with the first one.
The scheduler is the orchestrator that will handle the various jobs and triggers defined later. Usually, this code would be called when your application starts.
Private Async Sub CreateScheduler() 'construct a scheduler factory Dim colProperties As NameValueCollection = New NameValueCollection From { {"quartz.serializer.type", "binary"} } Dim objFactory As StdSchedulerFactory = New StdSchedulerFactory(colProperties) 'get a scheduler mobjScheduler = Await objFactory.GetScheduler() 'create a fake holiday Dim objCalendar As HolidayCalendar = New HolidayCalendar() objCalendar.AddExcludedDate(DateTime.Today) Await mobjScheduler.AddCalendar("myHolidays", objCalendar, False, False) 'start the scheduler Await mobjScheduler.Start() End Sub
private async void CreateScheduler() { // construct a scheduler factory NameValueCollection colProperties = new NameValueCollection { { "quartz.serializer.type", "binary" } }; StdSchedulerFactory objFactory = new StdSchedulerFactory(colProperties); // get a scheduler _scheduler = await objFactory.GetScheduler(); // create a fake holiday HolidayCalendar objCalendar = new HolidayCalendar(); objCalendar.AddExcludedDate(DateTime.Today); //not a valid holiday - just for demo await _scheduler.AddCalendar("myHolidays", objCalendar, false, false); // start the scheduler await _scheduler.Start(); }
We will reuse that mobjScheduler (_scheduler in C#) object all over the place. It will be the entry point of everything we will schedule later.
By the way, you can see in these lines that an object of type HolidayCalendar is initialized. This will be used in one of the examples. You don’t have to create one if you don’t need to prevent running tasks on holidays. The library does not come with a pre-set calendar of holidays as it greatly varies from one country to another. That means that if you want to support that feature, you will need to feed the calendar yourself.
Initializing the job
The second part required by Quartz.Net is the job.
Using the JobBuilder class and its fluent syntax, we will define a job is a class that defines the things to be performed. The first thing we need to provide to the JobBuilder is the type (the name of the class implementing from the IJob interface implementing the Execute method). It is more or less a pointer to a class that will be executed on a schedule. In the next step, we will define one or more triggers to indicate when the job will be executed. We will provide an identity to this job object (a name that will help us identify this particular job later).
'define the job and tie it to our job class Dim objJob As IJobDetail = JobBuilder. Create(Of JobWriteToTextFile)(). WithIdentity("myJob1", "group1"). Build()
// define the job and tie it to our job class IJobDetail objJob = JobBuilder .Create<JobWriteToTextFile>() .WithIdentity("myJob1", "group1") .Build();
Creating the job class
Each job that we will run, needs to be defined as a class implementing the IJob interface. The requirement of this interface is simple: expose a single Execute method.
Public Class JobWriteToTextFile Implements IJob Public Function Execute(context As IJobExecutionContext) As Task Implements IJob.Execute 'optional: extract parameters sent by the trigger Dim dataMap As JobDataMap = context.MergedJobDataMap Dim strFilename As String = dataMap.GetString("param1") 'the demo job just write to a text file Using tr As TextWriter = New StreamWriter(strFilename & ".txt", True) tr.WriteLine("This value is written at: " & DateTime.Now) tr.Close() End Using Return Task.CompletedTask End Function End Class
public class JobWriteToTextFile : IJob { public Task Execute(IJobExecutionContext context) { //optional: extract parameters sent by the trigger JobDataMap dataMap = context.MergedJobDataMap; string strFilename = dataMap.GetString("param1"); //the demo job just write to a text file using (TextWriter tr = new StreamWriter(strFilename + ".txt", true)) { tr.WriteLine("This value is written at: " + DateTime.Now); tr.Close(); } return Task.CompletedTask; } }
It is very important that this class includes a parameter-less constructor. If you don’t, Quartz.Net will simply not be able to create an instance of this class and your job will just never execute without triggering any error.
Creating triggers
So now that we have a job declared, we need to tell the scheduler when to run it. A trigger dictates when a job is executed.
A single job can have more then one trigger. And this is what my demo will be doing, creating 4 triggers to demonstrate different options supported by this library.
Consider this code:
'Create a first trigger on the job Dim objTrigger1 As ITrigger = TriggerBuilder. Create(). WithIdentity("myTrigger1", "group1"). UsingJobData("param1", "Trigger1FileName"). UsingJobData("param2", "Fake value - we can have more than one"). StartNow(). WithSimpleSchedule(Sub(x) x.WithIntervalInSeconds(10).RepeatForever() End Sub). ModifiedByCalendar("myHolidays"). 'but not on holidays Build()
// Create a first trigger on the job ITrigger objTrigger1 = TriggerBuilder.Create() .WithIdentity("myTrigger1", "group1") .UsingJobData("param1", "Trigger1FileName") .UsingJobData("param2", "Fake value - we can have more than one") .StartNow() .WithSimpleSchedule(x => x .WithIntervalInSeconds(10) .RepeatForever()) .ModifiedByCalendar("myHolidays") // but not on holidays .Build();
Using the TriggerBuilder class fluent syntax, we can create a trigger object providing something to identify it (WithIdentity), optionally that passes parameters to the class (UsingJobData), that starts now and that will repeat every 10 seconds forever.
If you have a single trigger, you would be ready to bind the trigger and the job to the scheduler by using this code:
await mobjscheduler.ScheduleJob(objJob, objTrigger1) 'if you have a single trigger
await _scheduler.ScheduleJob(objJob, objTrigger1); //if you have a single trigger
But since I have more features I want to introduce, we will create 3 more triggers.
The second trigger reads like this:
'A second trigger on the very same job - WithRepeatCount & ModifiedByCalendar Dim objTrigger2 As ITrigger = TriggerBuilder. Create(). WithIdentity("myTrigger2", "group1"). UsingJobData("param1", "Trigger2FileName"). StartNow(). WithSimpleSchedule(Sub(x) x.WithIntervalInSeconds(10).WithRepeatCount(10) End Sub). Build()
// A second trigger on the very same job - WithRepeatCount & ModifiedByCalendar ITrigger objTrigger2 = TriggerBuilder.Create() .WithIdentity("myTrigger2", "group1") .UsingJobData("param1", "Trigger2FileName") .StartNow() .WithSimpleSchedule(x => x .WithIntervalInSeconds(10) .WithRepeatCount(10)) .Build();
Notice that we are giving a different name to this trigger in the Withidentity method (the name has to be unique). Instead of executing the task forever, we are limiting the number of execution to 10 using the WithRepeatCount method. Lastly, remember the holiday calendar that was created when the scheduler was initialized, we are referencing it here by using the ModifiedByCalendar method. For the purpose of this demo, the holiday calendar contains today’s date. That means that this trigger will only start executing tomorrow at 10 seconds after midnight.
The previous examples were using the WithSimpleSchedule method to create the triggers. But criteria are not always that simple. You’ll be happy to learn that there is another of creating more complex conditions using Cron expressions.
You might not be familiar with Cron expressions like "0 0,15,30,45 8-10 ? * MON-FRI". Apparently, it is a common syntax when working with schedulers.
You can learn how to create Cron expression by going through the lesson 6 from the Quartz.Net documentation. And once you understand what the various parts are, I strongly recommend you going to https://www.freeformatter.com/cron-expression-generator-quartz.html. This site can build Cron expressions by simply selecting the various available options. It can also decipher a Cron expression that you have. Really neat site to bookmark.
So, consider the code of this third trigger:
'A third trigger on the very same job - Cron expressions Dim objTrigger3 As ITrigger = TriggerBuilder. Create(). WithIdentity("myTrigger3", "group1"). UsingJobData("param1", "Trigger3FileName"). WithCronSchedule("0 0,15,30,45 8-10 ? * MON-FRI"). Build()
// A third trigger on the very same job - Cron expressions ITrigger objTrigger3 = TriggerBuilder.Create() .WithIdentity("myTrigger3", "group1") .UsingJobData("param1", "Trigger3FileName") .WithCronSchedule("0 0,15,30,45 8-10 ? * MON-FRI") .Build();
It is using the WithCronSchedule. Can you read the expressions? Give a try to the website to decipher it.
There is yet another way of building simple trigger by using the WithSchedule and the CronScheduleBuilder classes which helps you build a trigger in a more fluent way. The fourth trigger reads like this:
'A fourth trigger on the very same job - Cron fluent syntax Dim objTrigger4 As ITrigger = TriggerBuilder. Create(). WithIdentity("myTrigger4", "group1"). UsingJobData("param1", "Trigger4FileName"). WithSchedule(CronScheduleBuilder.AtHourAndMinuteOnGivenDaysOfWeek(9, 45, {DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday})). Build()
// A fourth trigger on the very same job - Cron fluent syntax ITrigger objTrigger4 = TriggerBuilder.Create() .WithIdentity("myTrigger4", "group1") .UsingJobData("param1", "Trigger4FileName") .WithSchedule(CronScheduleBuilder.AtHourAndMinuteOnGivenDaysOfWeek(9, 45, new[] { DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday })) .Build();
This trigger should be a bit simpler to read. The trigger will execute the job at 9.45am Monday to Friday.
Before we had the job and its trigger(s) to a scheduler, it is always safer to check if it is already existing because duplicates are not allowed and will raise an exception. We use the same identity to check if an instance is already existing as shown here:
'Validate that the job doesn't already exists If Await mobjScheduler.CheckExists(New JobKey("myJob1", "group1")) Then Await mobjScheduler.DeleteJob(New JobKey("myJob1", "group1")) End If
// Validate that the job doesn't already exists if (await _scheduler.CheckExists(new JobKey("myJob1", "group1"))) await _scheduler.DeleteJob(new JobKey("myJob1", "group1"));
Previously, I have shown how to add a job with a single trigger to the schedule but there is also a way to add the 4 triggers at the same time:
'Tell quartz to schedule the job using our triggers Await mobjScheduler.ScheduleJob(objJob, {objTrigger1, objTrigger2, objTrigger3, objTrigger4}, True)
// Tell quartz to schedule the job using our triggers await _scheduler.ScheduleJob(objJob, new[] { objTrigger1, objTrigger2, objTrigger3, objTrigger4 }, true);
Pausing the scheduler
Because the scheduler has a hold on every jobs and triggers, some operations can be done easily like pausing and resuming. Have a look at this code snippet:
Private Sub btnPauseEverything_Click(sender As Object, e As EventArgs) Handles btnPauseEverything.Click If mobjScheduler Is Nothing OrElse Not mobjScheduler.IsStarted Then Return If btnPauseEverything.Tag Is Nothing Then btnPauseEverything.Tag = "Paused" mobjScheduler.PauseAll() btnPauseEverything.Text = "Resume Scheduler" Else btnPauseEverything.Tag = Nothing mobjScheduler.ResumeAll() btnPauseEverything.Text = "Pause Scheduler" End If End Sub
private void btnPauseEverything_Click(object sender, EventArgs e) { if (_scheduler == null || !_scheduler.IsStarted) return; if (btnPauseEverything.Tag == null) { btnPauseEverything.Tag = "Paused"; _scheduler.PauseAll(); btnPauseEverything.Text = "Resume Scheduler"; } else { btnPauseEverything.Tag = null; _scheduler.ResumeAll(); btnPauseEverything.Text = "Pause Scheduler"; } }
This will toggle between pausing and/or resuming all the jobs of the scheduler (this demo only has one job but it would work with more).
And there are some other methods that will let you pause/resume only some jobs or triggers. You will use their identities to select them.
Getting the status of the scheduler
The last thing I want to demonstrate here is how to get the status of the content of the scheduler.
The scheduler object holds the state of each jobs and triggers in collections. You can simply loop through them to collect what is being scheduled
Private Shared Async Function GetSchedulerStatus(ByVal pScheduler As IScheduler) As Task(Of String) Dim strOutput As StringBuilder = New StringBuilder() Dim collJobGroups = Await pScheduler.GetJobGroupNames() For Each objJobGroup In collJobGroups Dim objGroupMatcher = GroupMatcher(Of JobKey).GroupContains(objJobGroup) Dim collJobKeys = Await pScheduler.GetJobKeys(objGroupMatcher) For Each objJobKey In collJobKeys Dim objJobDetail = Await pScheduler.GetJobDetail(objJobKey) Dim collTriggers = Await pScheduler.GetTriggersOfJob(objJobKey) For Each objTrigger As ITrigger In collTriggers strOutput.AppendLine(objJobGroup) strOutput.AppendLine(objJobKey.Name) strOutput.AppendLine(objJobDetail.Description) strOutput.AppendLine(objTrigger.Key.Name) strOutput.AppendLine(objTrigger.Key.Group) strOutput.AppendLine(objTrigger.[GetType]().Name) strOutput.AppendLine("State: " + (Await pScheduler.GetTriggerState(objTrigger.Key)).ToString()) Dim strExceptionMessage As String = pScheduler.GetTriggerState(objTrigger.Key).Exception?.Message If Not String.IsNullOrWhiteSpace(strExceptionMessage) Then strOutput.AppendLine("Exception: " + strExceptionMessage) End If Dim nextFireTime As DateTimeOffset? = objTrigger.GetNextFireTimeUtc() If nextFireTime.HasValue Then strOutput.AppendLine("Next occurence: " + nextFireTime.Value.LocalDateTime.ToString(CultureInfo.InvariantCulture)) End If Dim previousFireTime As DateTimeOffset? = objTrigger.GetPreviousFireTimeUtc() If previousFireTime.HasValue Then strOutput.AppendLine("Previous occurence: " + previousFireTime.Value.LocalDateTime.ToString(CultureInfo.InvariantCulture)) End If Next Next Next Return strOutput.ToString() End Function
static async Task<string> GetSchedulerStatus(IScheduler pScheduler) { StringBuilder strOutput = new StringBuilder(); var collJobGroups = await pScheduler.GetJobGroupNames(); foreach (var objJobGroup in collJobGroups) { var objGroupMatcher = GroupMatcher<JobKey>.GroupContains(objJobGroup); var collJobKeys = await pScheduler.GetJobKeys(objGroupMatcher); foreach (var objJobKey in collJobKeys) { var objJobDetail = await pScheduler.GetJobDetail(objJobKey); var collTriggers = await pScheduler.GetTriggersOfJob(objJobKey); foreach (ITrigger objTrigger in collTriggers) { strOutput.AppendLine(objJobGroup); strOutput.AppendLine(objJobKey.Name); strOutput.AppendLine(objJobDetail.Description); strOutput.AppendLine(objTrigger.Key.Name); strOutput.AppendLine(objTrigger.Key.Group); strOutput.AppendLine(objTrigger.GetType().Name); strOutput.AppendLine("State: " + await pScheduler.GetTriggerState(objTrigger.Key)); string strExceptionMessage = pScheduler.GetTriggerState(objTrigger.Key).Exception?.Message; if (!string.IsNullOrWhiteSpace(strExceptionMessage)) strOutput.AppendLine("Exception: " + strExceptionMessage); DateTimeOffset? nextFireTime = objTrigger.GetNextFireTimeUtc(); if (nextFireTime.HasValue) { strOutput.AppendLine("Next occurence: " + nextFireTime.Value.LocalDateTime.ToString(CultureInfo.InvariantCulture)); } DateTimeOffset? previousFireTime = objTrigger.GetPreviousFireTimeUtc(); if (previousFireTime.HasValue) { strOutput.AppendLine("Previous occurence: " + previousFireTime.Value.LocalDateTime.ToString(CultureInfo.InvariantCulture)); } } } } return strOutput.ToString(); }
Building the UI
To be able to test the scheduler and the triggers, we have to build some kind of application. In real life, your scheduler is probably better suited in a Windows Service but it is much easier to demonstrate and debug in a Windows Forms application.
For the purpose of the demo, I have created a form with 3 buttons and a textbox to display the results as shown in figure 2.
Figure 2: The demo application in action
And there is a lot more…
I have only scratched the surface of the library here. It should be enough for you to decide if this library can be useful for you or not.
If you still have interest, you should really continue digging in the other features of Quartz.Net like triggers and jobs listeners, scheduler listeners and persistence. Remoting is also a feature that you will probably want to dig into. That feature lets you run the jobs from one process (maybe as a Windows service) and connect a UI to this process running on the same computer or from a different one.
Conclusion
If like me you have issues with the existing scheduler tool provided by Windows and want to build your own, or if you just want a simple scheduling mechanism in your app, be sure to have a look at Quartz.Net. It is really worth it.