You probably know it – you have a more complex system for processing large volumes of data and from time to time you need to do some automated maintenance – typically delete all items older than a few days. There are many ways to achieve this. I tried to come up with something simple and effective.
Before I start describing how my scheduler, with which I went into 40 lines of code, works, I will describe what other options there are and why I did not find them suitable. There is no universal best solution, of course, it all depends on the specific project. I use this scheduler in Azure Cloud Service.
Alternative approaches
Scheduled task
If some code is to be executed periodically in Windows, it is nonsense to make a process that will wait 99% of the time and do something the rest of the time. Task Scheduler is used for this. However, since I needed to run in Azure Cloud Service, I was looking for a solution purely in the .NET environment.
Azure Scheduler and Storage Queue
A good choice is to configure the Job Action in the Scheduler, which periodically inserts messages into the Storage Queue. These messages are then removed by the Worker Role and the Worker Role performs the desired task.
It is a great pity that the message lifetime is not automatically set to the length of the period with which the task is performed. When the message lifetime is longer than the period, there may be two messages in the queue that execute the same task immediately after each other, and we don't want that. If the period is longer than 1 week, which is the default lifetime of the message, it doesn't matter. However, if it is shorter, for example one minute, the task will be performed 15 times after the update, which takes a quarter of an hour.
The advantage of Sheduler is that it keeps track of how many jobs have been forged successfully and how many have failed. Another plus is the integration of the retry policy.
System.Threading.Thread.Sleep
Starting a thread, executing a task, waiting, forging a task, waiting again, and so on in the loop is nothing that would not work. But if I want to forge the task every day at 3 a.m., regardless of when the program starts, it needs some extra logic.
System.Threading.Timer
A timer is a useful helper, but if a task takes longer than its period, it will eventually overwhelm the entire virtual machine. Not that something can't be done about it, but again it requires extra logic.
Demands
What do we actually want from such a scheduler?
- Running an asynchronous task repeatedly.
- The period will not be longer than 24 hours.
- Running the task at a specific time (e.g. always at 3 am).
- Certainty that the next task will not start before the previous one is completed.
- Cancellation of scheduled tasks via CancellationToken.
- Dependency only on the .NET platform.
Source code
The Scheduler is built on top of the Timer class. The whole art lies in two things.
- Depending on what time the task should run, we will calculate how long it should take for Timer to wake up.
- We schedule only one awakening at a time. We schedule the next one after the task is over. public class Scheduler {
private readonly Timer timer;
private readonly Func<Task> work;
private readonly CancellationToken cancellationToken;
private readonly TimeSpan period;
private readonly TimeSpan utcStartupTime;
public Scheduler(Func<Task> work, TimeSpan period, TimeSpan utcStartupTime, CancellationToken cancellationToken) {
if (work == null) throw new ArgumentNullException(nameof(work));
if (utcStartupTime.Ticks > TimeSpan.TicksPerDay) throw new ArgumentOutOfRangeException(nameof(utcStartupTime));
if (period.Ticks < 0) throw new ArgumentOutOfRangeException(nameof(period));
if (utcStartupTime.Ticks < 0) throw new ArgumentOutOfRangeException(nameof(utcStartupTime));
if (cancellationToken == null) throw new ArgumentNullException(nameof(cancellationToken));
this.work = work;
this.cancellationToken = cancellationToken;
this.period = period;
this.utcStartupTime = utcStartupTime;
timer = new Timer(Callback);
ScheduleTimeout();
Task.Factory.StartNew(WaitForCancellation, TaskCreationOptions.LongRunning);
}
private void Callback(object state) {
work().Wait();
ScheduleTimeout();
}
private void ScheduleTimeout() {
if (cancellationToken.IsCancellationRequested) return;
var utcTimeOfDay = DateTime.UtcNow.TimeOfDay;
var dueTime = TimeSpan.FromTicks(((TimeSpan.TicksPerDay + utcStartupTime.Ticks - utcTimeOfDay.Ticks) % TimeSpan.TicksPerDay) % period.Ticks);
timer.Change(dueTime, Timeout.InfiniteTimeSpan);
}
private void WaitForCancellation() {
cancellationToken.WaitHandle.WaitOne();
timer.Change(Timeout.Infinite, Timeout.Infinite);
}
}
Use
And how do I use Scheduler? We will give him what to start, how often, at what time and when it should all end. We can try it in the console application:
private static CancellationTokenSource cts = new CancellationTokenSource();
private static int i = 0;
public static void Main(string[] args) {
var sch = new Scheduler(Work, TimeSpan.FromSeconds(2), new TimeSpan(12, 0, 0), cts.Token);
cts.Token.WaitHandle.WaitOne();
}
private static async Task Work() {
Console.WriteLine($"{DateTime.Now.TimeOfDay} Work started.");
Interlocked.Increment(ref i);
Console.WriteLine(i);
await Task.Delay(TimeSpan.FromSeconds(5));
if (i == 5) cts.Cancel();
Console.WriteLine($"{DateTime.Now.TimeOfDay} Work completed.");
}
The task should run every 2 seconds, but so that it runs at noon (which in this particular case means that it will run every even second). However, it lasts 5 seconds, so it will run every 6. After the 5th launch, it triggers a signal that cancels the next launch and terminates the entire application.