Skip to content

Commit 3e6a26f

Browse files
committed
Better calculation in CalculateTimeToNext to prevent task failures during DST changes
1 parent d22e895 commit 3e6a26f

File tree

2 files changed

+97
-44
lines changed

2 files changed

+97
-44
lines changed

ClockworkFramework.Core/Interval.cs

Lines changed: 80 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -76,39 +76,74 @@ public Interval(DayOfWeek dayOfWeek, int frequency, int hour, int minute, string
7676

7777
public TimeSpan CalculateTimeToNext(DateTime fromDateTime)
7878
{
79-
DateTime? next = null;
79+
// For relative intervals (Second/Minute/Hour), do arithmetic in UTC to completely
80+
// avoid DST issues. These intervals don't care about wall-clock time.
81+
if (TimeType == TimeType.Second || TimeType == TimeType.Minute || TimeType == TimeType.Hour)
82+
{
83+
DateTime fromUtc = fromDateTime.Kind == DateTimeKind.Utc
84+
? fromDateTime
85+
: fromDateTime.ToUniversalTime();
86+
87+
DateTime nextUtc = TimeType switch
88+
{
89+
TimeType.Second => fromUtc.AddSeconds(Frequency),
90+
TimeType.Minute => fromUtc.AddMinutes(Frequency),
91+
TimeType.Hour => fromUtc.AddHours(Frequency),
92+
_ => throw new Exception("Unexpected TimeType"),
93+
};
94+
95+
return nextUtc - fromUtc;
96+
}
97+
98+
// For absolute intervals (Day/Week/Year), we need to calculate in local time
99+
// (or the specified timezone) since the user wants "run at HH:MM local time".
100+
TimeZoneInfo tz;
101+
if (!string.IsNullOrEmpty(Timezone))
102+
{
103+
try
104+
{
105+
tz = TimeZoneInfo.FindSystemTimeZoneById(Timezone); //I could use https://github.com/mattjohnsonpint/TimeZoneConverter to support IANA timezones
106+
}
107+
catch (TimeZoneNotFoundException)
108+
{
109+
throw new Exception($"Unrecognized timezone '{Timezone}'");
110+
}
111+
}
112+
else
113+
{
114+
tz = TimeZoneInfo.Local;
115+
}
116+
117+
// Convert fromDateTime to the target timezone for accurate wall-clock calculations
118+
DateTime fromUtcAbs = fromDateTime.Kind == DateTimeKind.Utc
119+
? fromDateTime
120+
: fromDateTime.ToUniversalTime();
121+
DateTime localNow = TimeZoneInfo.ConvertTimeFromUtc(fromUtcAbs, tz);
122+
123+
DateTime? nextLocal = null;
80124
switch (TimeType)
81125
{
82-
case TimeType.Second:
83-
next = fromDateTime.AddSeconds(Frequency);
84-
break;
85-
case TimeType.Minute:
86-
next = fromDateTime.AddMinutes(Frequency);
87-
break;
88-
case TimeType.Hour:
89-
next = fromDateTime.AddHours(Frequency);
90-
break;
91126
case TimeType.Day:
92-
next = fromDateTime;
93-
next = new DateTime(next.Value.Year, next.Value.Month, next.Value.Day, Hour, Minute, 0, 0);
127+
nextLocal = new DateTime(localNow.Year, localNow.Month, localNow.Day, Hour, Minute, 0, 0);
94128

95-
if (Frequency != 1 || next < fromDateTime) //Only shift forward if the task repeats less frequently than everyday or the time has already passed
129+
if (Frequency != 1 || nextLocal <= localNow) //Only shift forward if the task repeats less frequently than everyday or the time has already passed
96130
{
97-
next = next.Value.AddDays(Frequency);
131+
nextLocal = nextLocal.Value.AddDays(Frequency);
98132
}
99133

100134
break;
101135
case TimeType.Week:
102-
next = fromDateTime;
103-
next = new DateTime(next.Value.Year, next.Value.Month, next.Value.Day, Hour, Minute, 0, 0);
136+
nextLocal = new DateTime(localNow.Year, localNow.Month, localNow.Day, Hour, Minute, 0, 0);
104137

105-
if (fromDateTime.DayOfWeek != DayOfWeek || next < fromDateTime) //Only shift forward if the DayOfWeek occurrence is not today or the time has already passed
138+
if (localNow.DayOfWeek != DayOfWeek || nextLocal <= localNow) //Only shift forward if the DayOfWeek occurrence is not today or the time has already passed
106139
{
107-
next = fromDateTime.AddDays(((int)DayOfWeek - (int)fromDateTime.DayOfWeek + 7) % 7);
140+
int daysUntilTarget = ((int)DayOfWeek - (int)localNow.DayOfWeek + 7) % 7;
141+
if (daysUntilTarget == 0) daysUntilTarget = 7; // If same day but time passed, go to next week
142+
nextLocal = new DateTime(localNow.Year, localNow.Month, localNow.Day, Hour, Minute, 0, 0).AddDays(daysUntilTarget);
108143

109144
if (Frequency > 1)
110145
{
111-
next = next.Value.AddDays(7 * Frequency);
146+
nextLocal = nextLocal.Value.AddDays(7 * (Frequency - 1));
112147
}
113148
}
114149

@@ -117,44 +152,48 @@ public TimeSpan CalculateTimeToNext(DateTime fromDateTime)
117152
//Todo: not currently supported
118153
break;
119154
case TimeType.Year:
120-
next = fromDateTime;
121-
next = new DateTime(next.Value.Year, next.Value.Month, next.Value.Day, Hour, Minute, 0, 0);
155+
nextLocal = new DateTime(localNow.Year, localNow.Month, localNow.Day, Hour, Minute, 0, 0);
122156

123-
if (next < fromDateTime) //Only shift forward if the time has already passed
157+
if (nextLocal <= localNow) //Only shift forward if the time has already passed
124158
{
125-
next = fromDateTime.AddYears(Frequency);
159+
nextLocal = nextLocal.Value.AddYears(Frequency);
126160
}
127161

128162
break;
129163
}
130164

131-
if (!next.HasValue)
165+
if (!nextLocal.HasValue)
132166
{
133167
throw new Exception("Unable to calculate next occurence");
134168
}
135169

136-
if (!string.IsNullOrEmpty(Timezone))
170+
// Handle DST edge cases before converting to UTC:
171+
// - "Invalid" times fall in the spring-forward gap (e.g. 2:30 AM when clocks skip 2:00->3:00)
172+
// - "Ambiguous" times occur during fall-back (e.g. 1:30 AM happens twice)
173+
if (tz.IsInvalidTime(nextLocal.Value))
137174
{
138-
try
139-
{
140-
//This is a rudimentary first version. Some potential problems that might arise,
141-
//causing this to provide inaccurate results:
142-
// - At the bottom, we attempt to account for DST changes, but by converting timezones,
143-
// I wouldn't be surprised if there were multiple DST or similar changes in between
144-
// - Etc. (Timezones can be VERY weird. This could fail in a variety of ways)
175+
// The target time doesn't exist — shift forward by the DST adjustment amount
176+
// so we land just after the gap (e.g. 2:30 AM becomes 3:30 AM)
177+
TimeSpan dstDelta = tz.GetAdjustmentRules()
178+
.Where(r => r.DateStart <= nextLocal.Value && r.DateEnd >= nextLocal.Value)
179+
.Select(r => r.DaylightDelta)
180+
.FirstOrDefault();
145181

146-
TimeZoneInfo timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(Timezone); //I could use https://github.com/mattjohnsonpint/TimeZoneConverter to support IANA timezones
182+
if (dstDelta == TimeSpan.Zero)
183+
dstDelta = TimeSpan.FromHours(1); // Fallback: most DST transitions are 1 hour
147184

148-
next = TimeZoneInfo.ConvertTime(next.Value, timeZoneInfo);
149-
}
150-
catch (TimeZoneNotFoundException)
151-
{
152-
throw new Exception($"Unrecognized timezone '{Timezone}'");
153-
}
185+
nextLocal = nextLocal.Value.Add(dstDelta);
154186
}
155187

156-
//Using UTC converts here gives us the correct TimeSpan even if there is a DST change in between
157-
return TimeZoneInfo.ConvertTimeToUtc(next.Value) - TimeZoneInfo.ConvertTimeToUtc(fromDateTime);
188+
// For ambiguous times (fall-back), ConvertTimeToUtc defaults to standard time offset.
189+
// This is acceptable — the task will run during the second occurrence of that wall-clock
190+
// time, which is a reasonable behavior for a scheduler.
191+
192+
// Convert both times to UTC for an accurate TimeSpan regardless of DST transitions
193+
DateTime nextUtcAbs = TimeZoneInfo.ConvertTimeToUtc(
194+
DateTime.SpecifyKind(nextLocal.Value, DateTimeKind.Unspecified), tz);
195+
196+
return nextUtcAbs - fromUtcAbs;
158197
}
159198
}
160199
}

ClockworkFramework/TaskRunner.cs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,23 @@ public static async Task RunTaskPeriodicAsync(IClockworkTaskBase taskBase, Metho
1212

1313
while (true)
1414
{
15-
DateTime now = DateTime.Now;
16-
TimeSpan waitTime = interval.CalculateTimeToNext(now);
17-
DateTime nextExecution = now + waitTime;
15+
TimeSpan waitTime;
16+
DateTime nextExecution;
17+
try
18+
{
19+
DateTime now = DateTime.Now;
20+
waitTime = interval.CalculateTimeToNext(now);
21+
nextExecution = now + waitTime;
22+
}
23+
catch (Exception ex)
24+
{
25+
// If CalculateTimeToNext fails (e.g. due to an unexpected DST edge case),
26+
// log the error and retry after a short delay rather than permanently killing the task
27+
Console.WriteLine($"[{DateTime.Now}] Task '{taskMethod.Name}' failed to calculate next run time: {ex.Message}. Retrying in 60 seconds.");
28+
exceptionHandler?.Invoke(ex);
29+
await Task.Delay(TimeSpan.FromSeconds(60), cancellationToken);
30+
continue;
31+
}
1832

1933
await Task.Delay(waitTime, cancellationToken);
2034

0 commit comments

Comments
 (0)