In this post, we will learn about Quartz and do a task scheduling with C#. The aim is to make a simple Windows form (desktop) application and schedule a job or jobs.
Introduction
Here, we are going to learn about Quartz and do a task scheduling with C#. The aim is to make a simple Windows form (desktop) application and schedule a job or jobs. The desktop application will have things like:
- Options
- Start/Pause
- Confirmations and force stop at the middle of a running job
- Skip time overlapping tasks
- Visual Information
- Next estimated start time
- Actual start time
- End time
- Task result (able to run without facing any error or not)
- Running multiple jobs in the same application
- Configurable schedule times for each job form configuration file
Projects
Schedule.Single
A scheduling context that will run only a single job.
Schedule.Multiple
A scheduling context that will be run for multiple jobs. An example where we are running two jobs called "Email
" and "Hr
".
Prerequisites
The prerequisites are as below:
Core
Here, we are going to create a shared/core DLL project. It will contain all adapter, event, and Quartz related codes. All other UI or client projects need to add reference from this project.
Schedule Delegates
Some event helper delegates:
public delegate void TaskExecution();
public delegate void TaskExecutionComplete(Exception exception);
public delegate bool ShouldTaskExecution();
Schedule Interface
This is a simple schedule structure.
public interface ISchedule
{
string Name { get; }
event TaskExecution TaskStarted;
event TaskExecution TaskVetoed;
event TaskExecutionComplete TaskExecuted;
event ShouldTaskExecution TaskShouldBeVetoed;
bool IsStarted();
bool IsTaskRunning();
bool IsPaused();
void Start();
void Pause();
void Resume();
DateTime? NextEstimatedFireTime();
}
string Name { get; }
the name for a schedule event TaskExecution TaskStarted;
fired when the job of this schedule started event TaskExecution TaskVetoed;
fired if scheduled job skipped event TaskExecutionComplete TaskExecuted;
fired when scheduled job finished event ShouldTaskExecution TaskShouldBeVetoed;
define if should we stop running planed job bool IsStarted();
check if scheduled started bool IsTaskRunning();
check if the scheduled job still in progress bool IsPaused();
check if the schedule paused or not void Start();
start schedule job void Pause();
pause already running schedule job void Resume(); r
esume the schedule DateTime? NextEstimatedFireTime();
get next estimated run time
Schedule Job Listener
IJobListener
is from the Quartz library, implementing it to creating our own schedule job listener. Here, we are adding additional events.
public class ScheduleJobListener : IJobListener
{
public event TaskExecution Started;
public event TaskExecution Vetoed;
public event TaskExecutionComplete Executed;
public ScheduleJobListener(string name)
{
Name = name;
}
public void JobToBeExecuted(IJobExecutionContext context)
{
Started?.Invoke();
}
public void JobExecutionVetoed(IJobExecutionContext context)
{
Vetoed?.Invoke();
}
public void JobWasExecuted
(IJobExecutionContext context, JobExecutionException jobException)
{
Executed?.Invoke(jobException);
}
public string Name { get; }
}
Schedule Trigger Listener
ITriggerListener
is from the Quartz library, implementing it to creating our own schedule trigger listener. Here, we are adding additional events.
public class ScheduleTriggerListener : ITriggerListener
{
public event ShouldTaskExecution ShouldVeto;
public ScheduleTriggerListener(string name)
{
Name = name;
}
public void TriggerFired(ITrigger trigger, IJobExecutionContext context)
{
}
public bool VetoJobExecution(ITrigger trigger, IJobExecutionContext context)
{
if (ShouldVeto == null)
{
return false;
}
else
{
return ShouldVeto();
}
}
public void TriggerMisfired(ITrigger trigger)
{
}
public void TriggerComplete(ITrigger trigger, IJobExecutionContext context,
SchedulerInstruction triggerInstructionCode)
{
}
public string Name { get; }
}
Please read more about job listener and schedule trigger part from here.
Schedule
This is our main schedule base class, implementing our previously shared ISchedule
interface.
public abstract class Schedule : ISchedule
{
public event TaskExecution TaskStarted;
public event TaskExecution TaskVetoed;
public event TaskExecutionComplete TaskExecuted;
public event ShouldTaskExecution TaskShouldBeVetoed;
protected readonly IScheduler Scheduler;
public readonly string ScheduleName;
public string Name
{
get
{
return ScheduleName;
}
}
private string TriggerName
{
get
{
return ScheduleName + "Trigger";
}
}
private string JobName
{
get
{
return ScheduleName + "Job";
}
}
private string JobListenerName
{
get
{
return ScheduleName + "JobListener";
}
}
private string TriggerListenerName
{
get
{
return ScheduleName + "TriggerListener";
}
}
private bool _isStarted;
protected Schedule(IScheduler scheduler, string name)
{
if (String.IsNullOrEmpty(name))
{
throw new NullReferenceException("Schedule Name required");
}
if (scheduler == null)
{
throw new NullReferenceException("Scheduler required");
}
ScheduleName = name;
Scheduler = scheduler;
}
public bool IsTaskRunning()
{
bool value = Scheduler.GetCurrentlyExecutingJobs().Any(x =>
x.JobDetail.Key.Name.Equals(JobName, StringComparison.OrdinalIgnoreCase));
return value;
}
protected void ScheduleTask(JobBuilder jobBuilder, TriggerBuilder triggerBuilder)
{
IJobDetail job = jobBuilder.WithIdentity(JobName).Build();
ITrigger trigger = AssignTriggerName(triggerBuilder).Build();
Scheduler.ScheduleJob(job, trigger);
}
private TriggerBuilder AssignTriggerName(TriggerBuilder triggerBuilder)
{
return triggerBuilder.WithIdentity(TriggerName);
}
protected abstract Tuple<JobBuilder, TriggerBuilder> Settings();
public bool IsStarted()
{
bool value = _isStarted;
return value;
}
public void Start()
{
Tuple<JobBuilder, TriggerBuilder> setting = Settings();
ScheduleTask(setting.Item1, setting.Item2);
AttachJobListener();
_isStarted = true;
}
public bool IsPaused()
{
TriggerKey key = new TriggerKey(TriggerName);
bool value = Scheduler.GetTriggerState(key) == TriggerState.Paused;
return value;
}
public void Pause()
{
Scheduler.PauseJob(new JobKey(JobName));
}
public void Interrupt()
{
Scheduler.Interrupt(new JobKey(JobName));
}
public void Resume()
{
Scheduler.ResumeJob(new JobKey(JobName));
}
protected void Reschedule(TriggerBuilder triggerBuilder)
{
TriggerKey key = new TriggerKey(TriggerName);
ITrigger trigger = AssignTriggerName(triggerBuilder).Build();
Scheduler.RescheduleJob(key, trigger);
}
protected void AttachJobListener()
{
ScheduleJobListener jobListener = new ScheduleJobListener(JobListenerName);
jobListener.Started += TaskStarted;
jobListener.Vetoed += TaskVetoed;
jobListener.Executed += TaskExecuted;
Scheduler.ListenerManager.AddJobListener
(jobListener, KeyMatcher<JobKey>.KeyEquals(new JobKey(JobName)));
ScheduleTriggerListener triggerListener =
new ScheduleTriggerListener(TriggerListenerName);
triggerListener.ShouldVeto += TaskShouldBeVetoed;
Scheduler.ListenerManager.AddTriggerListener
(triggerListener, KeyMatcher<TriggerKey>.KeyEquals(new TriggerKey(TriggerName)));
}
public DateTime? NextEstimatedFireTime()
{
TriggerKey key = new TriggerKey(TriggerName);
DateTimeOffset? timeUtc = Scheduler.GetTrigger(key).GetNextFireTimeUtc();
DateTime? dateTime = timeUtc == null
? (DateTime?)null
: timeUtc.Value.ToLocalTime().DateTime;
return dateTime;
}
protected TriggerBuilder CreateTrigger(string cronJobExpression)
{
return TriggerBuilder.Create().WithCronSchedule(cronJobExpression);
}
}
In Quartz, we attach the same schedule job to multiple triggers, It is as many to many relations. To make things in control, here we are designing the system, so that we can attach one schedule job to one trigger only, like one to one relation. In the same way, one schedule should have one job and a trigger.
Schedule Context
This is the scheduling base context class. A schedule context can have multiple schedules.
public abstract class ScheduleContext
{
protected NameValueCollection Props { get; set; }
protected IScheduler Scheduler { get; private set; }
protected void Start()
{
var factory = Props == null ? new StdSchedulerFactory() :
new StdSchedulerFactory(Props);
Scheduler = factory.GetScheduler();
Scheduler.Start();
}
public void PauseAll()
{
Scheduler.PauseAll();
}
public void ResumeAll()
{
Scheduler.ResumeAll();
}
public void Stop()
{
if (!Scheduler.IsShutdown)
{
Scheduler.Shutdown();
Scheduler = null;
}
}
public void WaitAndStop()
{
if (!Scheduler.IsShutdown)
{
Scheduler.Shutdown(true);
Scheduler = null;
}
}
}
Create a Schedule
Schedule Job
Here we are creating a job, which we are going to use in a schedule.
[PersistJobDataAfterExecution]
[DisallowConcurrentExecution]
public class TestScheduleJob : IJob
{
public void Execute(IJobExecutionContext context)
{
}
}
Schedule
Let's create a schedule using our schedule job.
public class TestSchedule : Core.Schedule
{
public TestSchedule(IScheduler scheduler) : base(scheduler, "TestSchedule")
{
}
protected override Tuple<JobBuilder, TriggerBuilder> Settings()
{
string cronJobExpression = ConfigurationManager.AppSettings["CronJobExpression"];
TriggerBuilder triggerBuilder =
TriggerBuilder.Create().WithCronSchedule(cronJobExpression);
JobBuilder jobBuilder = JobBuilder.Create<TestScheduleJob>();
return new Tuple<JobBuilder, TriggerBuilder>(jobBuilder, triggerBuilder);
}
}
override Tuple<JobBuilder, TriggerBuilder> Settings()
the method is using our previously created schedule job and creating a trigger by reading a cron expression from app.config file.
<add key="CronJobExpression" value="0/5 0-59 0-23 * * ?" />
Schedule Context
Here, we are creating a scheduling context. Now we are using only one schedule. But for multiple windows, there will be two or many as needed.
public class TestScheduleContext : ScheduleContext
{
private TestSchedule _testSchedule;
public TestSchedule TestSchedule
{
get
{
_testSchedule = _testSchedule ?? new TestSchedule(Scheduler);
return _testSchedule;
}
}
public TestScheduleContext()
{
Props = new NameValueCollection
{
{"quartz.scheduler.instanceName", nameof(TestScheduleContext)}
};
Start();
}
}
UI
Let's start creating a Windows Form.
public readonly uint MaxLine;
public readonly string DateTimeDisplayFormat;
public readonly uint VetoedTimeOffset;
public readonly BackgroundWorker Bw;
private TestScheduleContext _scheduleContext;
private DateTime _lastEstimatedStartTime;
public Form1()
{
InitializeComponent();
MaxLine = Convert.ToUInt16(ConfigurationManager.AppSettings["ReachTextBoxMaxLine"]);
DateTimeDisplayFormat = ConfigurationManager.AppSettings["DateTimeDisplayFormat"];
VetoedTimeOffset = Convert.ToUInt16(ConfigurationManager.AppSettings
["VetoedTimeOffsetMilliSeconds"]);
Bw = new BackgroundWorker();
Bw.WorkerSupportsCancellation = true;
Bw.DoWork += StartScheduler;
Bw.RunWorkerCompleted += BgWorkerCompleted;
Bw.RunWorkerAsync();
}
public readonly uint MaxLine;
maximum number of line to be displayed on UI window - p
ublic readonly string DateTimeDisplayFormat;
Display DateTime string
format public readonly uint VetoedTimeOffset;
offset time, if we what to veto any job
We are doing to read the above values from app.config file. We also need to run things in the background, so we are going to use BackgroundWorker
.
private void StartScheduler(object sender, DoWorkEventArgs e)
{
if (Bw.CancellationPending)
{
e.Cancel = true;
return;
}
_scheduleContext = new TestScheduleContext();
this.Invoke((MethodInvoker)delegate
{
chkBoxStartEnd.Appearance = Appearance.Button;
chkBoxStartEnd.TextAlign = ContentAlignment.MiddleCenter;
chkBoxStartEnd.MinimumSize = new Size(75, 25);
chkBoxStartEnd.Text = "Start";
});
}
private void BgWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
if (e.Error != null)
{
Log.WriteError("SchedulerTest", e.Error);
this.Invoke((MethodInvoker)delegate
{
richTextBox1.AddLine("Error to Run!");
richTextBox1.ScrollToCaret();
});
}
}
Start/Pause
It is actually a toggle button in the UI.
StartSchedule()
starts/resumes the scheduling context and its single schedule. There are some event attachments inside this method, please check them. It prints the next estimated start date time in UI. PausedSchedule()
pauses the scheduling context and prints pause status in UI.
private void chkBoxStartEnd_Click(object sender, EventArgs e)
{
if (chkBoxStartEnd.Checked)
{
StartSchedule();
chkBoxStartEnd.Text = "Pause";
}
else
{
PausedSchedule();
chkBoxStartEnd.Text = "Start";
}
}
private void StartSchedule()
{
if (!_scheduleContext.TestSchedule.IsStarted())
{
_scheduleContext.TestSchedule.TaskStarted += BeforeStart;
_scheduleContext.TestSchedule.TaskVetoed += Vetoed;
_scheduleContext.TestSchedule.TaskExecuted += AfterEnd;
_scheduleContext.TestSchedule.TaskShouldBeVetoed += TaskShouldBeVetoed;
_scheduleContext.TestSchedule.Start();
}
else
{
_scheduleContext.TestSchedule.Resume();
}
PrintNextEstimatedStartTime(_scheduleContext.TestSchedule.NextEstimatedFireTime());
}
private void PausedSchedule()
{
_scheduleContext.TestSchedule.Pause();
if (_scheduleContext.TestSchedule.IsTaskRunning())
{
MessageBox.Show("Task still running, will be paused after task ends.");
return;
}
this.Invoke((MethodInvoker)delegate
{
richTextBox1.AddLine("Paused", MaxLine);
richTextBox1.ScrollToCaret();
});
}
By clicking the start button, the schedule will start and will display the Pause button.
If we click the Pause button, the application will try to stop its currently running schedule. If a scheduled job is in progress, it will show a confirmation window.
Before Start And After End
BeforeStart()
fired every time before the scheduled job starts. It prints the actual start date time in UI. AfterEnd(Exception exception)
fired every time after the scheduled job ends. It prints the end date time and result section in UI. If any error to run the scheduled job, the result will be displayed as Error else Success will be printed. We are also using an error logger Log.WriteError("SchedulerTest", exception)
here. It is also going to print the next estimated start date time in UI.
private void PrintNextEstimatedStartTime(DateTime? dateTime)
{
string msg = "Estimated: ";
if (dateTime != null)
{
_lastEstimatedStartTime = ((DateTime) dateTime);
msg += _lastEstimatedStartTime.ToString(DateTimeDisplayFormat);
}
this.Invoke((MethodInvoker)delegate
{
richTextBox1.AddLine(msg, MaxLine);
richTextBox1.ScrollToCaret();
});
}
private void BeforeStart()
{
string startDateTime = DateTime.Now.ToString(DateTimeDisplayFormat);
this.Invoke((MethodInvoker)delegate
{
richTextBox1.AppendText("\t\t" + "Started: " + startDateTime);
richTextBox1.ScrollToCaret();
});
}
private void AfterEnd(Exception exception)
{
this.Invoke((MethodInvoker)delegate
{
richTextBox1.AppendText("\t\t" + "Ended: " +
DateTime.Now.ToString(DateTimeDisplayFormat));
richTextBox1.ScrollToCaret();
});
string status = String.Empty;
if (exception != null)
{
status = "Error";
Log.WriteError("SchedulerTest", exception);
}
else
{
status = "Success";
}
this.Invoke((MethodInvoker)delegate
{
richTextBox1.AppendText("\t\t" + "Result: " + status);
richTextBox1.ScrollToCaret();
});
if (_scheduleContext.TestSchedule.IsPaused())
{
this.Invoke((MethodInvoker)delegate
{
richTextBox1.AddLine("Paused", MaxLine);
richTextBox1.ScrollToCaret();
});
}
else
{
PrintNextEstimatedStartTime(_scheduleContext.TestSchedule.NextEstimatedFireTime());
}
}
Veto
bool TaskShouldBeVetoed()
determines if need to skip the current schedule job. void Vetoed()
fired every time after the scheduled job vetoed. It prints vetoed date time and the next estimated start date time in UI. In this case, no end date time and result section will be printed.
private bool TaskShouldBeVetoed()
{
DateTime nowDateTime = DateTime.Now.TrimMilliseconds();
DateTime tillDateTime = _lastEstimatedStartTime.TrimMilliseconds().AddMilliseconds
(VetoedTimeOffset);
bool dateTimeNowInRange = nowDateTime <= tillDateTime;
var value = !dateTimeNowInRange;
return value;
}
private void Vetoed()
{
string dateTime = DateTime.Now.ToString(DateTimeDisplayFormat);
this.Invoke((MethodInvoker)delegate
{
richTextBox1.AppendText("\t\t" + "Vetoed: " + dateTime);
richTextBox1.ScrollToCaret();
});
PrintNextEstimatedStartTime(_scheduleContext.TestSchedule.NextEstimatedFireTime());
}
Closing the Form
If anyone clicks the window close button, we are going to check if any schedule job is running or not. Plus it is important to make sure the application is not running in the background anymore. I found this background running issue only in the form application, not in a console app.
CloseApp()
makes sure that the application will stop without running in the background Form1_FormClosing(object sender, FormClosingEventArgs e)
fires when someone clicks the close button. Need to attach this method with the form's FormClosing
event. If a scheduled job is in progress, it will show a confirmation window.
private void CloseApp()
{
_scheduleContext.Stop();
if (Bw.IsBusy)
{
Bw.CancelAsync();
}
while (Bw.IsBusy)
{
Application.DoEvents();
}
System.Diagnostics.Process.GetCurrentProcess().Kill();
Application.Exit();
Environment.Exit(0);
}
private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
if (_scheduleContext.TestSchedule.IsTaskRunning())
{
var window = MessageBox.Show(
"A task is in progress, do you still want to close?",
"Close Window",
MessageBoxButtons.YesNo);
if (window == DialogResult.Yes)
{
CloseApp();
}
e.Cancel = (window == DialogResult.No);
}
else
{
CloseApp();
}
}
app.config
<add key="ReachTextBoxMaxLine" value="720"/>
<add key="DateTimeDisplayFormat" value="dd-MMM-yyyy hh:mm:ss tt"/>
<add key="VetoedTimeOffsetMilliSeconds" value="0" />
<add key="CronJobExpression" value="0/5 0-59 0-23 * * ?" />
Projects
It is a Visual Studio 2017 solution. You may need to set a startup project.
Schedule.Core
(DLL project) Schedule.Ui.Core
(UI core) Schedule.Single
(Deployable exe project) Schedule.Multiple
(Deployable exe project)
The code may throw unexpected errors for untested inputs. If any, just let me know.
Upcoming
We can do the same things in:
- ASP.NET console project
- ASP.NET CORE console project
Will share projects soon...
Cron Expressions Get Started
Let's check what is cron expression, how to use, and some examples.
Cron Expression
Syntax
<second> <minute> <hour> <day-of-month> <month> <day-of-week> <year>
- The syntax should have 6-7 fields (minimum 6)
- One among <day-of-month> or <day-of-week> need to be ? (both ? doesn't work)
Cron Expressions Fields and Values
FIELD NAME | IS REQUIRED | ALLOWED VALUES | ALLOWED SPECIAL CHARACTERS |
Seconds | Yes | 0-59 | , - * / |
Minutes | Yes | 0-59 | , - * / |
Hours | Yes | 0-23 | , - * / |
Day of Month | Yes | 1-31 | , - * ? / L W |
Month | Yes | 1-12 or JAN-DEC | , - * ? / |
Day of Week | Yes | 1-7 or SUN-SAT | , - * ? / L # |
Year | No | Empty, 1970-2099 | , - * |
Cron Expressions Special Characters
CHARACTER
| WHAT IT MEANS IN CRON EXPRESSION
|
*
| Used to select all values in a field. For example, "*" in the minute field means "every minute" and "*" in the month field means every month.
|
?
| Used to indicate no specific value, i.e., when the value doesn't matter. For example, if you want the instance to run on a particular day of the month (say, the 10th), but don't care what day of the week that happens to be, you would put "10" in the day-of-month field, and "?" in the day-of-week field.
|
-
| Used to specify a range. For example, "10-12" in the hour field means "the hours 10, 11 and 12".
|
,
| Used to specify additional values. For example, "MON,WED,FRI" in the day-of-week field means "the days Monday, Wednesday, and Friday."
|
/
| Used to specify increments. For example, "5/15" in the second field means "run the instance every 15 seconds starting at second 5. This would be at seconds 5, 20, 35, and 50". "1/3" in the day-of-month field means "run the instance every 3 days starting on the first day of the month".
|
L
| ("last") This character has a different meaning in the day of month and day of the week. For example, the value "L" in the day-of-month field means "the last day of the month" - day 31 for January, day 28 for February on non-leap years. If used in the day-of-week field by itself, it simply means "7" or "SAT". But if used in the day-of-week field after another value, it means "the last xxx day of the month" - for example, "6L" means "the last Friday of the month". When using the "L" option, it is important not to specify lists, or ranges of values.
|
W
| ("weekday") - used to specify the weekday (Monday-Friday) nearest the given day. For example, if you were to specify "15W" as the value for the day-of-month field, the meaning is: "the nearest weekday to the 15th of the month". So if the 15th is a Saturday, the instance will run on Friday the 14th. If the 15th is a Sunday, the trigger will fire on Monday the 16th. If the 15th is a Tuesday, then it will fire on Tuesday the 15th. However, if you specify "1W" as the value for day-of-month, and the 1st is a Saturday, the trigger will fire on Monday the 3rd, as it will not jump over the boundary of a month's days. The "W" character can only be specified when the day-of-month is a single day, not a range or list of days.
|
LW
| The L and W characters can be combined in the day-of-month field to specify "last weekday of the month".
|
#
| Used to specify "the nth" day of the month.
For example, the value of "6#3" in the day-of-week field means "the third Friday of the month" (day 6 = Friday and "#3" = the 3rd one in the month).
"2#1" = The first Monday of the month
"4#5" = The fifth Wednesday of the month.
Note: If you specify "#5" and there is no 5th occurrence of the given day of the week in the month, then no run will occur that month.
|
More Details
Cron Expression Examples
Online Cron Expression Testing
Every Second
* * * * * * /*does not work, need one ?*/
* * * ? * *
* * * * * ?
Every minute, 45th-second
45 0-59 0-23 * * ?
Every hour, 10 minute interval starting from 00 minute
0 0/10 * * * ?
0 0,10,20,30,40,50 * * * ?
Every hour, 10 minute interval starting from 05 minute
0 5/10 * * * ?
Every day once, at 02:30:00 AM
0 30 2 * * ?
10 PM to 6 AM string from 00 minute, 30 minute interval
0 0/30 22-23,23,0-6 * * ?
Every 15th minute 00 second of any hour
0 15 * * * ?
Every Saturday starting at 00 minute, 30 minute interval
0 0/30 * ? * 7
History
- 30th July, 2020: Initial version