Introduction
A few months back, I had a need to develop a service that performed a specific task on a regular basis. Not being content with a merely fulfilling the requirements, I decided that a more comprehensive (and infinitely more re-usable) solution was called for. This article discusses that solution.
The Problem
At it's face, the problem was a simple one - implement a method for predicting the date/time of a scheduled event. However, once I started defining the possible variations, it became apparent that more work would be required than initially planned. Some of the questions that presented themselves were:
- What intervals do I want to support?
- In the case of schedules that involved periods of days, weeks, or months, what time of day should an event be triggered?
The Solution
First, I made the decision to ignore seconds and milliseconds because when you're dealing with something that's on a repetitive schedule over a long period of time, you're usually dealing with rather loose timing constraints - one second one way or the other isn't going to hurt anything. Besides, you need to avoid bogging down the CPU with needless time checks every 5 milliseconds, so I think it's an acceptable trade-off. Given the open nature of the code, feel free to change this aspect if you desire.
Next, I had to come up with some usable "modes", so I decided on the following:
- Hourly
- Daily
- Weekly
- First day of the month
- Last day of the month
- Specific day of the month
- Specific time interval
I know, some of them seem redundant, but hey use what you need, and discard the rest - grin.
Then, I decided that I would accept a date from which to determine a future date, based on the specified mode. At that point I came up with the prototype for the required method. Finally, I created a class that contains nothing but static methods. This frees us of the requirement to create an instance of the object before using its methods. All of the methods are public, but there are two that are especially helpful.
public static DateTime CalculateNextTriggerTime(DateTime currentTime,
TimeSpan processTime,
ScheduleMode scheduleMode)
This method accepts the date from which we will be calculating the future date, a TimeSpan
object that is used in various ways depending on which mode you use, and the Schedule mode itself. This is where the explanation of how it works gets a little tricky. By default, if you pass in a TimeSpan
that's zeroed out (new TimeSpan(0,0,0,0,0)
), the processTime
will have no affect on the caluclated future time. With the exception of the SpecificInterval
mode, the time of day for all future dates will be set to the top of the hour for the Hourly
mode, and midnight for the remaining modes. The processTime
value is used to modify those calculated future dates. Once again, the seconds and milliseconds properties are ignored. Each case in the switch statement is shown below, and I've decided that the code is commented well enough to not require additional descriptive text.
ScheduleMode.Hourly
The processTime.Minutes
property is added to the calculated future date (remember, it defaults to the top of the hour). This allows you to specify an hourly schedule that happens at irregular times (like at hour:15, or hour:30).
case ScheduleMode.Hourly:
{
if (nextTime.Minute < processTime.Minutes)
{
nextTime = nextTime.AddMinutes(
processTime.Minutes - nextTime.Minute);
}
else
{
nextTime = nextTime.Subtract(new TimeSpan(0, 0,
nextTime.Minute,
nextTime.Second,
nextTime.Millisecond));
nextTime = nextTime.Add(new TimeSpan(0, 1,
processTime.Minutes,
0, 0));
}
}
break;
ScheduleMode.Daily
The processTime.Hours
and .Minutes
properties are used to specify the time of the day at which you want to calculate. The .Days property
is ignored.
case ScheduleMode.Daily:
{
nextTime = nextTime.Subtract(new TimeSpan(0,
nextTime.Hour,
nextTime.Minute,
nextTime.Second,
nextTime.Millisecond));
nextTime = nextTime.Add(new TimeSpan(1, processTime.Hours,
processTime.Minutes, 0, 0));
}
break;
ScheduleMode.Weekly
The processTime.Days
property specifies the numeric day of the week on which to calculate, while the .Hours
and .Minutes
properties are used to specify the time of the day.
case ScheduleMode.Weekly:
{
int daysToAdd = 0;
int dayNumber = (int)nextTime.DayOfWeek;
if (dayNumber == processTime.Days)
{
daysToAdd = 7;
}
else
{
if (dayNumber > processTime.Days)
{
daysToAdd = 7 - dayNumber;
}
else
{
daysToAdd = processTime.Days - dayNumber;
}
}
nextTime = nextTime.AddDays(daysToAdd);
nextTime = nextTime.Subtract(new TimeSpan(0, nextTime.Hour,
nextTime.Minute,
nextTime.Second,
nextTime.Millisecond));
nextTime = nextTime.Add(new TimeSpan(0, processTime.Hours,
processTime.Minutes, 0, 0));
}
break;
ScheduleMode.FirstDayOfMonth
The processTime.Hours
and .Minutes
properties are used to specify the time of the day. The .Days property
is ignored.
case ScheduleMode.FirstDayOfMonth:
{
int daysThisMonth = DaysInMonth(nextTime);
int today = nextTime.Day;
if (today == 1)
{
nextTime = nextTime.AddDays(daysThisMonth);
}
else
{
nextTime = nextTime.AddDays((daysThisMonth - today) + 1);
}
nextTime = nextTime.Subtract(new TimeSpan(0,
nextTime.Hour,
nextTime.Minute,
nextTime.Second,
nextTime.Millisecond));
nextTime = nextTime.Add(new TimeSpan(0, processTime.Hours,
processTime.Minutes, 0, 0));
}
break;
ScheduleMode.LastDayOfMonth
The processTime.Hours
and .Minutes
properties are used to specify the time of the day. The .Days property
is ignored.
case ScheduleMode.LastDayOfMonth:
{
int daysThisMonth = DaysInMonth(nextTime);
int today = nextTime.Day;
if (today == daysThisMonth)
{
int daysNextMonth = DaysInMonth(nextTime.AddDays(1));
nextTime = nextTime.AddDays(daysNextMonth);
}
else
{
nextTime = nextTime.AddDays(daysThisMonth - today);
}
nextTime = nextTime.Subtract(new TimeSpan(0,
nextTime.Hour,
nextTime.Minute,
nextTime.Second,
nextTime.Millisecond));
nextTime = nextTime.Add(new TimeSpan(0, processTime.Hours,
processTime.Minutes, 0, 0));
}
break;
ScheduleMode.DayOfMonth
The processTime.Hours
and .Minutes
properties are used to specify the time of the day. The .Days property
is ignored.
case ScheduleMode.DayOfMonth:
{
int leapDay = 0;
if (nextTime.Month == 2 && !IsLeapYear(nextTime) &&
processTime.Days == 29)
{
leapDay = 1;
}
int daysToAdd = 0;
if (nextTime.Day < processTime.Days)
{
daysToAdd = processTime.Days - nextTime.Day - leapDay;
}
else
{
daysToAdd = (DaysInMonth(nextTime) - nextTime.Day) +
processTime.Days - leapDay;
}
nextTime = nextTime.AddDays(daysToAdd);
nextTime = nextTime.Subtract(new TimeSpan(0,
nextTime.Hour,
nextTime.Minute,
nextTime.Second,
nextTime.Millisecond));
nextTime = nextTime.Add(new TimeSpan(0, processTime.Hours,
processTime.Minutes, 0, 0));
}
break;
ScheduleMode.SpecificInterval
The processTime
object is the entire basis for calculated the future date.
case ScheduleMode.SpecificInterval:
{
if (nextTime.Second >= 30)
{
nextTime = nextTime.AddSeconds(60 - nextTime.Second);
}
nextTime = nextTime.Subtract(new TimeSpan(0, 0, 0,
nextTime.Second,
nextTime.Millisecond));
nextTime = nextTime.Add(processTime);
}
break;
Finally, before returning the newly calculated future date, the seconds and milliseconds are once again zeroed out, just to make sure the date/time is pure.
nextTime = nextTime.Subtract(new TimeSpan(0, 0, 0,
nextTime.Second,
nextTime.Millisecond));
The next most useful function is how we determine equality. I was really kind of surprised (and even dismayed) at how unreasonably difficult it is to determine date equality. First, I simply tried comparing two DateTime
objects, but if I ever got an "equal" state, it was by mere chance. Next, I tried comparing the Ticks
property from the two DateTime
s. Again, any "equal" state that resulted was just dumb luck. After trying to figure out what the hell was wrong - it struck me.
The internal representation of a DateTime
is a floating point number. That's when I came up with my ultimate solution - convert each date to a 64-bit integer and compare the integers. Here are the methods:
public static Int64 ConvertToInt64(DateTime date)
{
string dateStr = date.ToString("yyyyMMddHHmm");
Int64 dateValue = Convert.ToInt64(dateStr);
return dateValue;
}
public static DateCompareState CompareDates(DateTime now, DateTime target)
{
DateCompareState state = DateCompareState.Equal;
now = now.Subtract(new TimeSpan(0, 0, 0, now.Second, now.Millisecond));
target = target.Subtract(new TimeSpan(0, 0, 0, target.Second, target.Millisecond));
Int64 nowValue = ConvertToInt64(now);
Int64 targetValue = ConvertToInt64(target);
if (nowValue < targetValue)
{
state = DateCompareState.Earlier;
}
else
{
if (nowValue > targetValue)
{
state = DateCompareState.Later;
}
}
return state;
}
I used the DateTime.ToString()
method to create a custom string representation of the date, converted the string to a Int64
, and came up with numbers that could be reliably compared. Remember, we don't have to worry about seconds or milliseconds in this class, so a Int64
will always be big enough. If a need ever arises to require seconds and milliseconds, I could always switch to a decimal
type.
The Sample Application
The sample application has two listboxes sitting side-by-side. The left-most one allows you to fire off a thread that calculates a future date 2 minutes in the future. You have to click the Start button to make it go, and it keeps going until you close the application. The right-most listbox displays examples of using all of the scheduling modes, and is populated immediately upon staring the application.
A BackgroundWorker object is used to continuously schedule an event, and is a perfect example of how you probably want to implement it in your own code. Here's the DoWork function (since it's fully commented, I won't bother with additional text about it in this article):
private void backgroundWorker_DoWork(object sender, DoWorkEventArgs e)
{
BackgroundWorker worker = sender as BackgroundWorker;
DateTime nextTime = DateTime.Now;
TimeSpan interval = new TimeSpan(0, 0, m_triggerMinutes, 0, 0);
ScheduleMode mode = ScheduleMode.SpecificInterval;
DateCompareState compareState;
int tick = 0;
int sleepTime = 250;
int checkAt = 1000;
while (!worker.CancellationPending)
{
nextTime = DateScheduler.CalculateNextTriggerTime(nextTime, interval,
mode);
tick = 0;
while (!worker.CancellationPending)
{
if (tick % checkAt == 0)
{
compareState = DateScheduler.CompareDates(
DateTime.Now, nextTime);
tick = 0;
if (compareState == DateCompareState.Equal)
{
break;
}
}
Thread.Sleep(sleepTime);
tick += sleepTime;
}
if (!worker.CancellationPending)
{
worker.ReportProgress(0);
}
}
}
As you can see, the source code is fairly well documented and there's several examples of using the class, so go forth and schedule to your hearts content.
History
10/01/2008: Original article.