fix: Changes EventScheduler API so it is more explicit (#2164)
### Summary
Refactors ScheduledEvent and EventScheduler API to use TimeOnly so recurrence offset is explicit.
API:
```cs
public ScheduledEvent(
DateTime startAfter,
DateTime endOn,
TimeOnly time,
IRecurrencePattern recurrence,
TimeZoneInfo timeZone = null
)
```
Example:
```cs
// Schedule a daily event at 8:00 AM UTC
EventScheduler.DailyAt(
new DateTime(2024, 6, 1, 8, 0, 0, DateTimeKind.Utc),
() => Console.WriteLine("Daily event triggered!")
);
// Schedule a custom recurring event at 3:30 PM UTC every Monday
var recurrence = new WeeklyRecurrencePattern(1, DaysOfWeek.Monday);
EventScheduler.Shared.ScheduleEvent(
DateTime.UtcNow,
new TimeOnly(15, 30),
() => Console.WriteLine("Weekly Monday event!"),
recurrence
);
```
This commit is contained in:
parent
f89b2be653
commit
21a4092dd8
7 changed files with 495 additions and 250 deletions
2
.github/workflows/code_quality.yml
vendored
2
.github/workflows/code_quality.yml
vendored
|
|
@ -19,6 +19,6 @@ jobs:
|
|||
ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit
|
||||
fetch-depth: 0 # a full history is required for pull request analysis
|
||||
- name: 'Qodana Scan'
|
||||
uses: JetBrains/qodana-action@v2024.1
|
||||
uses: JetBrains/qodana-action@v2025.1
|
||||
env:
|
||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||
|
|
|
|||
|
|
@ -1471,12 +1471,6 @@ public static partial class Utility
|
|||
|
||||
public static DateTime LocalToUtc(this DateTime local, TimeZoneInfo tz)
|
||||
{
|
||||
if (tz.IsInvalidTime(local))
|
||||
{
|
||||
// For hourly recurrence, just subtract the standard offset (simulate as if the time exists)
|
||||
return DateTime.SpecifyKind(local - tz.BaseUtcOffset, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
if (tz.IsAmbiguousTime(local))
|
||||
{
|
||||
var offsets = tz.GetAmbiguousTimeOffsets(local);
|
||||
|
|
|
|||
|
|
@ -9,16 +9,62 @@ namespace UOContent.Tests;
|
|||
[Collection("Sequential Tests")]
|
||||
public class EventSchedulerTests
|
||||
{
|
||||
// Test implementations for controlled testing
|
||||
private class TestRecurrencePattern : IRecurrencePattern
|
||||
{
|
||||
private readonly TimeSpan _interval;
|
||||
private readonly int _maxOccurrences;
|
||||
private int _currentOccurrence;
|
||||
|
||||
public TestRecurrencePattern(TimeSpan interval, int maxOccurrences = int.MaxValue)
|
||||
{
|
||||
_interval = interval;
|
||||
_maxOccurrences = maxOccurrences;
|
||||
_currentOccurrence = 0;
|
||||
}
|
||||
|
||||
public DateTime GetNextOccurrence(DateTime afterUtc, TimeOnly time, TimeZoneInfo timeZone)
|
||||
{
|
||||
_currentOccurrence++;
|
||||
|
||||
if (_currentOccurrence > _maxOccurrences)
|
||||
{
|
||||
return DateTime.MaxValue;
|
||||
}
|
||||
|
||||
return afterUtc + _interval;
|
||||
}
|
||||
}
|
||||
|
||||
private class TestScheduledEvent : ScheduledEvent
|
||||
{
|
||||
public int CallCount { get; private set; }
|
||||
public Action Callback { get; }
|
||||
|
||||
public TestScheduledEvent(DateTime startAfter, Action callback, IRecurrencePattern recurrence = null)
|
||||
: base(startAfter, TimeOnly.FromDateTime(startAfter), recurrence, TimeZoneInfo.Utc)
|
||||
{
|
||||
CallCount = 0;
|
||||
Callback = callback;
|
||||
}
|
||||
|
||||
public override void OnEvent()
|
||||
{
|
||||
CallCount++;
|
||||
Callback?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private static void Init()
|
||||
{
|
||||
Core._now = DateTime.UtcNow;
|
||||
Core._now = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
Timer.Init(0);
|
||||
EventScheduler.Configure();
|
||||
}
|
||||
|
||||
private static void Finish()
|
||||
{
|
||||
EventScheduler.Instance.Stop();
|
||||
EventScheduler.Shared.Stop();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -27,153 +73,20 @@ public class EventSchedulerTests
|
|||
Init();
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
}
|
||||
finally
|
||||
{
|
||||
bool called = false;
|
||||
var evt = EventScheduler.Instance.ScheduleEvent(Core._now, () => called = true);
|
||||
var evt = EventScheduler.Shared.ScheduleEvent(
|
||||
Core._now,
|
||||
TimeOnly.FromDateTime(Core._now),
|
||||
() => called = true
|
||||
);
|
||||
|
||||
Timer.Slice(8);
|
||||
|
||||
Assert.True(called);
|
||||
Assert.Equal(Core._now, evt.NextOccurrence);
|
||||
}
|
||||
|
||||
Finish();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2024, 3, 10, 2, 0, "America/New_York")] // DST spring forward gap (invalid)
|
||||
[InlineData(2024, 11, 3, 1, 0, "America/New_York")] // DST fall back (ambiguous)
|
||||
[InlineData(2024, 6, 1, 5, 0, "America/New_York")] // Normal time
|
||||
public void HourlyRecurrence(
|
||||
int year, int month, int day, int hour, int minute, string tzId)
|
||||
{
|
||||
Init();
|
||||
|
||||
try
|
||||
{
|
||||
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
|
||||
var local = new DateTime(year, month, day, hour, minute, 0, DateTimeKind.Unspecified);
|
||||
|
||||
// Set Core._now to just before the target hour
|
||||
var beforeLocal = local.AddHours(-1);
|
||||
Core._now = beforeLocal.LocalToUtc(tz);
|
||||
|
||||
bool called = false;
|
||||
var evt = EventScheduler.Instance.ScheduleEvent(
|
||||
Core._now,
|
||||
() => called = true,
|
||||
EventScheduler.Hourly,
|
||||
tz
|
||||
);
|
||||
|
||||
// Advance to the target hour
|
||||
Core._now = local.LocalToUtc(tz);
|
||||
Assert.Equal(Core._now, evt.NextOccurrence);
|
||||
Timer.Slice(8);
|
||||
|
||||
// Hourly recurrence should always execute, even in DST gaps/ambiguous times
|
||||
Assert.True(called);
|
||||
Assert.Equal(Core._now.AddHours(1), evt.NextOccurrence);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Finish();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2024, 3, 10, 2, 0, "America/New_York")] // DST spring forward gap
|
||||
[InlineData(2024, 11, 3, 1, 30, "America/New_York")] // DST fall back
|
||||
[InlineData(2024, 6, 2, 5, 0, "America/New_York")] // Normal time
|
||||
public void MonthlyRecurrence(
|
||||
int year, int month, int day, int hour, int minute, string tzId)
|
||||
{
|
||||
Init();
|
||||
|
||||
try
|
||||
{
|
||||
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
|
||||
var local = new DateTime(year, month, day, hour, minute, 0, DateTimeKind.Unspecified);
|
||||
|
||||
Core._now = local.AddDays(-1).LocalToUtc(tz);
|
||||
|
||||
bool called = false;
|
||||
var evt = EventScheduler.Instance.ScheduleEvent(
|
||||
Core._now,
|
||||
() => called = true,
|
||||
EventScheduler.GetMonthlyRecurrence(day),
|
||||
tz
|
||||
);
|
||||
|
||||
Core._now = local.LocalToUtc(tz);
|
||||
var invalidTime = tz.IsInvalidTime(local);
|
||||
|
||||
// Invalid time ranges are not executed, so the occurrence is an additional month later
|
||||
Assert.Equal(invalidTime ? local.AddMonths(1).LocalToUtc(tz) : Core._now, evt.NextOccurrence);
|
||||
|
||||
Timer.Slice(8);
|
||||
if (invalidTime)
|
||||
{
|
||||
Assert.False(called);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.True(called);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Finish();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2024, 11, 3, 1, 30, DayOfWeek.Sunday, OrdinalDayOccurrence.First, "America/New_York")] // DST fallback
|
||||
[InlineData(2024, 3, 31, 0, 0, DayOfWeek.Sunday, OrdinalDayOccurrence.Last, "America/New_York")] // Last Sunday
|
||||
[InlineData(2024, 3, 31, 0, 0, DayOfWeek.Sunday, OrdinalDayOccurrence.Fifth, "America/New_York")] // Fifth Sunday
|
||||
[InlineData(2024, 4, 3, 0, 0, DayOfWeek.Sunday, OrdinalDayOccurrence.Fifth, "America/New_York")] // Fifth Sunday (Doesn't exist)
|
||||
public void MonthlyOrdinalRecurrence(
|
||||
int year, int month, int day, int hour, int minute, DayOfWeek dow, OrdinalDayOccurrence ordinal, string tzId)
|
||||
{
|
||||
Init();
|
||||
|
||||
try
|
||||
{
|
||||
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
|
||||
var local = new DateTime(year, month, day, hour, minute, 0).AddDays(-1);
|
||||
Core._now = local.LocalToUtc(tz);
|
||||
|
||||
bool called = false;
|
||||
var pattern = new MonthlyOrdinalRecurrencePattern(ordinal, dow);
|
||||
var evt = EventScheduler.Instance.ScheduleEvent(
|
||||
Core._now,
|
||||
() => called = true,
|
||||
pattern,
|
||||
tz
|
||||
);
|
||||
|
||||
var testTime = Core._now.AddDays(1);
|
||||
|
||||
if (month == 4 && ordinal == OrdinalDayOccurrence.Fifth)
|
||||
{
|
||||
// If there is no fifth occurrence, NextOccurrence should be in the next month
|
||||
var expectedNext = pattern.GetNextOccurrence(Core._now, tz);
|
||||
Assert.Equal(expectedNext, evt.NextOccurrence);
|
||||
Core._now = testTime;
|
||||
Timer.Slice(8);
|
||||
Assert.False(called);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(testTime, evt.NextOccurrence);
|
||||
Core._now = testTime;
|
||||
Timer.Slice(8);
|
||||
Assert.True(called);
|
||||
}
|
||||
EventScheduler.Shared.StopEvent(evt);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -182,70 +95,45 @@ public class EventSchedulerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void ScheduleEvent_NullCallback_Throws()
|
||||
public void Scheduler_OrdersEventsByNextOccurrence()
|
||||
{
|
||||
Init();
|
||||
|
||||
try
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
EventScheduler.Instance.ScheduleEvent(Core._now, null)
|
||||
var executionOrder = new List<int>();
|
||||
|
||||
// Create events with staggered occurrences
|
||||
var evt3 = new TestScheduledEvent(
|
||||
Core._now.AddSeconds(30),
|
||||
() => executionOrder.Add(3)
|
||||
);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Finish();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdvanceEvent_PastEndDate_DoesNotReschedule()
|
||||
{
|
||||
Init();
|
||||
var evt1 = new TestScheduledEvent(
|
||||
Core._now.AddSeconds(10),
|
||||
() => executionOrder.Add(1)
|
||||
);
|
||||
|
||||
try
|
||||
{
|
||||
bool called = false;
|
||||
var evt = new CallbackScheduledEvent(Core._now, Core._now, () => called = true);
|
||||
var evt2 = new TestScheduledEvent(
|
||||
Core._now.AddSeconds(20),
|
||||
() => executionOrder.Add(2)
|
||||
);
|
||||
|
||||
EventScheduler.Instance.ScheduleEvent(evt);
|
||||
// Add out of order
|
||||
EventScheduler.Shared.ScheduleEvent(evt3);
|
||||
EventScheduler.Shared.ScheduleEvent(evt1);
|
||||
EventScheduler.Shared.ScheduleEvent(evt2);
|
||||
|
||||
// Advance time to after all events
|
||||
Core._now = Core._now.AddSeconds(40);
|
||||
Timer.Slice(8);
|
||||
Assert.True(called);
|
||||
|
||||
Assert.DoesNotContain(
|
||||
evt,
|
||||
typeof(EventScheduler)
|
||||
.GetField(
|
||||
"_schedule",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance
|
||||
)!
|
||||
.GetValue(EventScheduler.Instance) as IEnumerable<CallbackScheduledEvent> ?? []
|
||||
);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Finish();
|
||||
}
|
||||
}
|
||||
// Verify they executed in time order, not addition order
|
||||
Assert.Equal([1, 2, 3], executionOrder);
|
||||
|
||||
[Theory]
|
||||
[InlineData("UTC")] // No DST adjustments
|
||||
[InlineData("Asia/Kathmandu")] // Unusual offset (UTC+5:45)
|
||||
public void LocalToUtc_SpecialTimeZones_HandlesCorrectly(string tzId)
|
||||
{
|
||||
Init();
|
||||
|
||||
try
|
||||
{
|
||||
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
|
||||
var local = new DateTime(2024, 6, 1, 12, 0, 0, DateTimeKind.Unspecified);
|
||||
var expected = TimeZoneInfo.ConvertTimeToUtc(local, tz);
|
||||
|
||||
var actual = local.LocalToUtc(tz);
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
Assert.Equal(DateTimeKind.Utc, actual.Kind);
|
||||
EventScheduler.Shared.StopEvent(evt1);
|
||||
EventScheduler.Shared.StopEvent(evt2);
|
||||
EventScheduler.Shared.StopEvent(evt3);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -254,27 +142,167 @@ public class EventSchedulerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalToUtc_EdgeCases_BeforeAndAfterTransition()
|
||||
public void Scheduler_HandlesRecurringEvents()
|
||||
{
|
||||
Init();
|
||||
|
||||
try
|
||||
{
|
||||
var tz = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
|
||||
// 1:59 AM (just before spring forward)
|
||||
var beforeSpring = new DateTime(2024, 3, 10, 1, 59, 0);
|
||||
// 3:01 AM (just after spring forward)
|
||||
var afterSpring = new DateTime(2024, 3, 10, 3, 1, 0);
|
||||
int callCount = 0;
|
||||
|
||||
var beforeUtc = beforeSpring.LocalToUtc(tz);
|
||||
var afterUtc = afterSpring.LocalToUtc(tz);
|
||||
// Create a recurrence pattern that fires every 10 seconds, up to 3 times
|
||||
var recurrence = new TestRecurrencePattern(TimeSpan.FromSeconds(10), 3);
|
||||
|
||||
// Should be 1 hour + 2 minutes apart in UTC (not 1 hour 2 minutes)
|
||||
Assert.Equal(2, (afterUtc - beforeUtc).TotalMinutes);
|
||||
var evt = new TestScheduledEvent(
|
||||
Core._now,
|
||||
() => callCount++,
|
||||
recurrence
|
||||
);
|
||||
|
||||
EventScheduler.Shared.ScheduleEvent(evt);
|
||||
|
||||
// Advance time to after all occurrences should have happened
|
||||
Core._now = Core._now.AddSeconds(50);
|
||||
Timer.Slice(8);
|
||||
|
||||
// Should have fired 4 times (3 recurrences)
|
||||
Assert.Equal(3, callCount);
|
||||
Assert.Equal(3, evt.CallCount);
|
||||
|
||||
EventScheduler.Shared.StopEvent(evt);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Finish();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scheduler_HandlesExceptionsInEvents()
|
||||
{
|
||||
Init();
|
||||
|
||||
try
|
||||
{
|
||||
bool failedEventCalled = false;
|
||||
bool laterEventCalled = false;
|
||||
|
||||
// Event that throws exception
|
||||
var failedEvt = EventScheduler.Shared.ScheduleEvent(
|
||||
Core._now.AddSeconds(10),
|
||||
() =>
|
||||
{
|
||||
failedEventCalled = true;
|
||||
throw new Exception("Test exception");
|
||||
}
|
||||
);
|
||||
|
||||
// Later event that should still execute
|
||||
var laterEvt = EventScheduler.Shared.ScheduleEvent(
|
||||
Core._now.AddSeconds(20),
|
||||
() => laterEventCalled = true
|
||||
);
|
||||
|
||||
// Advance time to after both events
|
||||
Core._now = Core._now.AddSeconds(30);
|
||||
Timer.Slice(8);
|
||||
|
||||
// Both should have been called despite the exception
|
||||
Assert.True(failedEventCalled);
|
||||
Assert.True(laterEventCalled);
|
||||
|
||||
EventScheduler.Shared.StopEvent(failedEvt);
|
||||
EventScheduler.Shared.StopEvent(laterEvt);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Finish();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scheduler_RemovesEventsCorrectly()
|
||||
{
|
||||
Init();
|
||||
|
||||
try
|
||||
{
|
||||
bool eventCalled = false;
|
||||
|
||||
var evt = EventScheduler.Shared.ScheduleEvent(
|
||||
Core._now.AddSeconds(10),
|
||||
() => eventCalled = true
|
||||
);
|
||||
|
||||
// Remove before execution
|
||||
EventScheduler.Shared.StopEvent(evt);
|
||||
|
||||
// Advance time
|
||||
Core._now = Core._now.AddSeconds(20);
|
||||
Timer.Slice(8);
|
||||
|
||||
// Event should not have executed
|
||||
Assert.False(eventCalled);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Finish();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scheduler_HandlesEventsWithEndDates()
|
||||
{
|
||||
Init();
|
||||
|
||||
try
|
||||
{
|
||||
int callCount = 0;
|
||||
|
||||
// Create a recurrence pattern that fires every 10 seconds
|
||||
var recurrence = new TestRecurrencePattern(TimeSpan.FromSeconds(10));
|
||||
|
||||
// Create a custom event with an end date
|
||||
var startTime = Core._now;
|
||||
var endTime = Core._now.AddSeconds(36);
|
||||
|
||||
var customEvent = new CustomEvent(
|
||||
startTime,
|
||||
endTime,
|
||||
TimeOnly.FromDateTime(startTime),
|
||||
recurrence,
|
||||
() => callCount++
|
||||
);
|
||||
|
||||
EventScheduler.Shared.ScheduleEvent(customEvent);
|
||||
|
||||
Core._now = Core._now.AddSeconds(50);
|
||||
Timer.Slice(8);
|
||||
|
||||
// Should have fired 3 times only (initial + 2 within the timeframe)
|
||||
Assert.Equal(3, callCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Finish();
|
||||
}
|
||||
}
|
||||
|
||||
private class CustomEvent : ScheduledEvent
|
||||
{
|
||||
private readonly Action _callback;
|
||||
|
||||
public CustomEvent(
|
||||
DateTime startAfter,
|
||||
DateTime endOn,
|
||||
TimeOnly time,
|
||||
IRecurrencePattern recurrence,
|
||||
Action callback
|
||||
) : base(startAfter, endOn, time, recurrence, TimeZoneInfo.Utc) => _callback = callback;
|
||||
|
||||
public override void OnEvent()
|
||||
{
|
||||
_callback?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
using System;
|
||||
using Server;
|
||||
using Server.Engines.Events;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests;
|
||||
|
||||
public class RecurrencePatternTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(2024, 5, 15, 12, 30, 1, 2024, 5, 15, 13, 30)] // Normal case
|
||||
[InlineData(2024, 5, 15, 23, 30, 2, 2024, 5, 16, 1, 30)] // Cross day boundary
|
||||
public void HourlyRecurrencePattern_GetNextOccurrence_ReturnsCorrectTime(
|
||||
int startYear, int startMonth, int startDay, int startHour, int startMinute,
|
||||
int intervalHours,
|
||||
int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute)
|
||||
{
|
||||
// Arrange
|
||||
var tz = TimeZoneInfo.Utc;
|
||||
var pattern = new HourlyRecurrencePattern(intervalHours);
|
||||
var afterUtc = new DateTime(startYear, startMonth, startDay, startHour, startMinute, 0, DateTimeKind.Utc);
|
||||
var time = new TimeOnly(0, expectedMinute);
|
||||
|
||||
// Act
|
||||
var result = pattern.GetNextOccurrence(afterUtc, time, tz);
|
||||
|
||||
// Assert
|
||||
var expected = new DateTime(expectedYear, expectedMonth, expectedDay, expectedHour, expectedMinute, 0, DateTimeKind.Utc);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2024, 5, 15, 12, 30, 1, 2024, 5, 16, 12, 30)] // Next day
|
||||
[InlineData(2024, 5, 31, 12, 30, 2, 2024, 6, 2, 12, 30)] // Across month boundary
|
||||
public void DailyRecurrencePattern_GetNextOccurrence_ReturnsCorrectDay(
|
||||
int startYear, int startMonth, int startDay, int startHour, int startMinute,
|
||||
int intervalDays,
|
||||
int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute)
|
||||
{
|
||||
// Arrange
|
||||
var tz = TimeZoneInfo.Utc;
|
||||
var pattern = new DailyRecurrencePattern(intervalDays);
|
||||
var afterUtc = new DateTime(startYear, startMonth, startDay, startHour, startMinute, 0, DateTimeKind.Utc);
|
||||
var time = new TimeOnly(expectedHour, expectedMinute);
|
||||
|
||||
// Act
|
||||
var result = pattern.GetNextOccurrence(afterUtc, time, tz);
|
||||
|
||||
// Assert
|
||||
var expected = new DateTime(expectedYear, expectedMonth, expectedDay, expectedHour, expectedMinute, 0, DateTimeKind.Utc);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2024, 11, 3, 1, 30, DayOfWeek.Sunday, OrdinalDayOccurrence.First, "America/New_York", 2024, 11, 3)]
|
||||
[InlineData(2024, 3, 31, 0, 0, DayOfWeek.Sunday, OrdinalDayOccurrence.Last, "America/New_York", 2024, 3, 31)]
|
||||
[InlineData(2024, 3, 31, 0, 0, DayOfWeek.Sunday, OrdinalDayOccurrence.Fifth, "America/New_York", 2024, 3, 31)]
|
||||
[InlineData(2024, 4, 3, 0, 0, DayOfWeek.Sunday, OrdinalDayOccurrence.Fifth, "America/New_York", 2024, 6, 30)] // June has 5 Sundays (2, 9, 16, 23, 30)
|
||||
public void MonthlyOrdinalRecurrencePattern_GetNextOccurrence_ReturnsCorrectDate(
|
||||
int startYear, int startMonth, int startDay, int startHour, int startMinute,
|
||||
DayOfWeek dow, OrdinalDayOccurrence ordinal, string tzId,
|
||||
int expectedYear, int expectedMonth, int expectedDay)
|
||||
{
|
||||
// Arrange
|
||||
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
|
||||
var pattern = new MonthlyOrdinalRecurrencePattern(ordinal, dow);
|
||||
|
||||
// Use a day BEFORE the expected date
|
||||
var startLocal = new DateTime(startYear, startMonth, startDay, startHour, startMinute, 0);
|
||||
var beforeUtc = startLocal.AddDays(-1).LocalToUtc(tz);
|
||||
var timeOnly = new TimeOnly(startHour, startMinute);
|
||||
|
||||
// Act
|
||||
var result = pattern.GetNextOccurrence(beforeUtc, timeOnly, tz);
|
||||
|
||||
// Assert - convert back to local for comparison
|
||||
var resultLocal = TimeZoneInfo.ConvertTimeFromUtc(result, tz);
|
||||
Assert.Equal(new DateTime(expectedYear, expectedMonth, expectedDay, startHour, startMinute, 0), resultLocal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("America/New_York", 2024, 3, 10, 2, 30)] // Spring forward - 2:30 AM doesn't exist
|
||||
[InlineData("America/New_York", 2024, 11, 3, 1, 30)] // Fall back - 1:30 AM happens twice
|
||||
public void MonthlyRecurrencePattern_DSTTransitions_HandlesCorrectly(
|
||||
string tzId, int year, int month, int day, int hour, int minute)
|
||||
{
|
||||
// Arrange
|
||||
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
|
||||
var pattern = new MonthlyRecurrencePattern(day);
|
||||
|
||||
var localDateBefore = new DateTime(year, month, day, 0, 0, 0);
|
||||
var beforeUtc = localDateBefore.LocalToUtc(tz);
|
||||
var timeOnly = new TimeOnly(hour, minute);
|
||||
|
||||
// Act - shouldn't throw exceptions
|
||||
var result = pattern.GetNextOccurrence(beforeUtc, timeOnly, tz);
|
||||
|
||||
// Assert - just make sure we got a valid result
|
||||
Assert.NotEqual(DateTime.MaxValue, result);
|
||||
|
||||
// For invalid times (spring forward), should skip to next valid time
|
||||
var resultLocal = TimeZoneInfo.ConvertTimeFromUtc(result, tz);
|
||||
if (month == 3) // Spring forward
|
||||
{
|
||||
// Should skip the invalid 2:30 AM time
|
||||
Assert.True(resultLocal.Hour >= 3 || resultLocal > new DateTime(year, month, day));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MonthlyOrdinalRecurrencePattern_DST_FallBack_HandlesAmbiguousTimeCorrectly()
|
||||
{
|
||||
// This tests specifically the DST fall back case that was failing
|
||||
var tz = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
|
||||
var pattern = new MonthlyOrdinalRecurrencePattern(OrdinalDayOccurrence.First, DayOfWeek.Sunday);
|
||||
|
||||
// November 3, 2024 at 1:30 AM - First Sunday, falls on DST transition
|
||||
var beforeDate = new DateTime(2024, 11, 2, 12, 0, 0);
|
||||
var beforeUtc = beforeDate.LocalToUtc(tz);
|
||||
var timeOnly = new TimeOnly(1, 30);
|
||||
|
||||
// Act
|
||||
var result = pattern.GetNextOccurrence(beforeUtc, timeOnly, tz);
|
||||
|
||||
// Assert - should be November 3, 2024 at 1:30 AM
|
||||
var resultLocal = TimeZoneInfo.ConvertTimeFromUtc(result, tz);
|
||||
Assert.Equal(new DateTime(2024, 11, 3, 1, 30, 0), resultLocal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MonthlyRecurrence_MonthWithoutDay_SkipsToNextValidMonth()
|
||||
{
|
||||
// Arrange
|
||||
var tz = TimeZoneInfo.Utc;
|
||||
var pattern = new MonthlyRecurrencePattern(31);
|
||||
|
||||
// February doesn't have 31 days
|
||||
var februaryDate = new DateTime(2024, 2, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var timeOnly = new TimeOnly(12, 0);
|
||||
|
||||
// Act
|
||||
var result = pattern.GetNextOccurrence(februaryDate, timeOnly, tz);
|
||||
|
||||
// Assert - should be March 31
|
||||
Assert.Equal(new DateTime(2024, 3, 31, 12, 0, 0), result);
|
||||
}
|
||||
|
||||
// Additional utility method tests
|
||||
[Theory]
|
||||
[InlineData("America/New_York", 2024, 11, 3, 1, 30)] // Fall back - ambiguous time
|
||||
public void LocalToUtc_AmbiguousTime_HandlesConsistently(
|
||||
string tzId, int year, int month, int day, int hour, int minute)
|
||||
{
|
||||
// Arrange
|
||||
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
|
||||
var localTime = new DateTime(year, month, day, hour, minute, 0);
|
||||
|
||||
// Act
|
||||
var result = localTime.LocalToUtc(tz);
|
||||
|
||||
// Assert - should be consistent, not testing exact value
|
||||
Assert.Equal(DateTimeKind.Utc, result.Kind);
|
||||
|
||||
// Convert back should give either standard or DST time
|
||||
var backToLocal = TimeZoneInfo.ConvertTimeFromUtc(result, tz);
|
||||
Assert.Equal(new DateTime(year, month, day, hour, minute, 0), backToLocal);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,22 +7,23 @@ public sealed class CallbackScheduledEvent : ScheduledEvent
|
|||
private readonly Action _callback;
|
||||
|
||||
public CallbackScheduledEvent(
|
||||
DateTime afterUtc,
|
||||
DateTime after,
|
||||
TimeOnly time,
|
||||
Action callback,
|
||||
IRecurrencePattern recurrencePattern = null,
|
||||
TimeZoneInfo timeZone = null
|
||||
) : base(afterUtc, recurrencePattern, timeZone) =>
|
||||
) : base(after, time, recurrencePattern, timeZone) =>
|
||||
_callback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
|
||||
public CallbackScheduledEvent(
|
||||
DateTime afterUtc,
|
||||
DateTime endDate,
|
||||
DateTime after,
|
||||
DateTime endOn,
|
||||
TimeOnly time,
|
||||
Action callback,
|
||||
IRecurrencePattern recurrencePattern = null,
|
||||
TimeZoneInfo timeZone = null
|
||||
) : base(afterUtc, endDate, recurrencePattern, timeZone) =>
|
||||
) : base(after, endOn, time, recurrencePattern, timeZone) =>
|
||||
_callback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
|
||||
|
||||
public override void OnEvent() => _callback();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,13 @@ public class HourlyRecurrencePattern : IRecurrencePattern
|
|||
|
||||
public HourlyRecurrencePattern(int intervalHours = 1) => IntervalHours = Math.Max(1, intervalHours);
|
||||
|
||||
public DateTime GetNextOccurrence(DateTime afterUtc, TimeZoneInfo timeZone) => afterUtc.AddHours(IntervalHours);
|
||||
public DateTime GetNextOccurrence(DateTime afterUtc, TimeOnly time, TimeZoneInfo timeZone)
|
||||
{
|
||||
var local = TimeZoneInfo.ConvertTimeFromUtc(afterUtc, timeZone);
|
||||
return new DateTime(local.Year, local.Month, local.Day, local.Hour, time.Minute, 0)
|
||||
.LocalToUtc(timeZone)
|
||||
.AddHours(IntervalHours);
|
||||
}
|
||||
}
|
||||
|
||||
public class DailyRecurrencePattern : IRecurrencePattern
|
||||
|
|
@ -17,7 +23,13 @@ public class DailyRecurrencePattern : IRecurrencePattern
|
|||
|
||||
public DailyRecurrencePattern(int intervalDays = 1) => IntervalDays = Math.Max(1, intervalDays);
|
||||
|
||||
public DateTime GetNextOccurrence(DateTime afterUtc, TimeZoneInfo timeZone) => afterUtc.AddDays(IntervalDays);
|
||||
public DateTime GetNextOccurrence(DateTime afterUtc, TimeOnly time, TimeZoneInfo timeZone)
|
||||
{
|
||||
var local = TimeZoneInfo.ConvertTimeFromUtc(afterUtc, timeZone);
|
||||
return new DateTime(local.Year, local.Month, local.Day, time.Hour, time.Minute, 0)
|
||||
.LocalToUtc(timeZone)
|
||||
.AddDays(IntervalDays);
|
||||
}
|
||||
}
|
||||
|
||||
public class WeeklyRecurrencePattern : IRecurrencePattern
|
||||
|
|
@ -31,10 +43,12 @@ public class WeeklyRecurrencePattern : IRecurrencePattern
|
|||
DaysOfWeek = daysOfWeek;
|
||||
}
|
||||
|
||||
public DateTime GetNextOccurrence(DateTime afterUtc, TimeZoneInfo timeZone)
|
||||
public DateTime GetNextOccurrence(DateTime afterUtc, TimeOnly time, TimeZoneInfo timeZone)
|
||||
{
|
||||
var local = TimeZoneInfo.ConvertTimeFromUtc(afterUtc, timeZone);
|
||||
var weekStart = local.Date.AddDays(1);
|
||||
var localDate = DateOnly.FromDateTime(local);
|
||||
|
||||
var weekStart = localDate.ToDateTime(time);
|
||||
var daysOfWeek = DaysOfWeek;
|
||||
|
||||
// Set the day of the week to whatever day it is now
|
||||
|
|
@ -46,7 +60,7 @@ public class WeeklyRecurrencePattern : IRecurrencePattern
|
|||
// Example:
|
||||
// Recurrence is Monday, Wednesday, Friday - and today is Wednesday
|
||||
// weekStart will be Thursday, and then we check every day for 7 days to find the next occurrence match.
|
||||
for (int i = 0; i < 7; i++)
|
||||
for (int i = 1; i < 8; i++)
|
||||
{
|
||||
var candidate = weekStart.AddDays(i);
|
||||
var candidateDay = (DaysOfWeek)(1 << (int)candidate.DayOfWeek);
|
||||
|
|
@ -72,12 +86,11 @@ public class MonthlyRecurrencePattern : IRecurrencePattern
|
|||
IntervalMonths = Math.Max(1, intervalMonths);
|
||||
}
|
||||
|
||||
public DateTime GetNextOccurrence(DateTime afterUtc, TimeZoneInfo timeZone)
|
||||
public DateTime GetNextOccurrence(DateTime afterUtc, TimeOnly time, TimeZoneInfo timeZone)
|
||||
{
|
||||
var local = TimeZoneInfo.ConvertTimeFromUtc(afterUtc, timeZone);
|
||||
var year = local.Year;
|
||||
var month = local.Month;
|
||||
var time = local.TimeOfDay;
|
||||
var day = DayOfMonth == -1 ? local.Day : DayOfMonth;
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
|
|
@ -86,7 +99,7 @@ public class MonthlyRecurrencePattern : IRecurrencePattern
|
|||
var candidate = new DateTime(year, 1, 1)
|
||||
.AddMonths(nextMonth - 1)
|
||||
.AddDays(day - 1)
|
||||
.Add(time);
|
||||
.Add(time.ToTimeSpan());
|
||||
|
||||
// Some months may not have that day of the month, if not, we skip to the next interval
|
||||
if (candidate > local && candidate.Day == day && !timeZone.IsInvalidTime(candidate))
|
||||
|
|
@ -112,7 +125,7 @@ public class MonthlyOrdinalRecurrencePattern : IRecurrencePattern
|
|||
DayOfWeek = dayOfWeek;
|
||||
}
|
||||
|
||||
public DateTime GetNextOccurrence(DateTime afterUtc, TimeZoneInfo timeZone)
|
||||
public DateTime GetNextOccurrence(DateTime afterUtc, TimeOnly time, TimeZoneInfo timeZone)
|
||||
{
|
||||
var local = TimeZoneInfo.ConvertTimeFromUtc(afterUtc, timeZone);
|
||||
var year = local.Year;
|
||||
|
|
@ -129,7 +142,8 @@ public class MonthlyOrdinalRecurrencePattern : IRecurrencePattern
|
|||
if (Ordinal >= OrdinalDayOccurrence.First)
|
||||
{
|
||||
// Find the first day of the month
|
||||
var firstOfMonth = new DateTime(candidateYear, candidateMonth, 1, local.Hour, local.Minute, local.Second);
|
||||
var firstOfMonth = new DateTime(candidateYear, candidateMonth, 1)
|
||||
.Add(time.ToTimeSpan());
|
||||
|
||||
// Find the first occurrence of the desired day
|
||||
int daysOffset = ((int)DayOfWeek - (int)firstOfMonth.DayOfWeek + 7) % 7;
|
||||
|
|
@ -149,7 +163,8 @@ public class MonthlyOrdinalRecurrencePattern : IRecurrencePattern
|
|||
{
|
||||
// Find the last day of the month
|
||||
var daysInMonth = DateTime.DaysInMonth(candidateYear, candidateMonth);
|
||||
var lastOfMonth = new DateTime(candidateYear, candidateMonth, daysInMonth, local.Hour, local.Minute, local.Second);
|
||||
var lastOfMonth = new DateTime(candidateYear, candidateMonth, daysInMonth)
|
||||
.Add(time.ToTimeSpan());
|
||||
|
||||
// Find the last occurrence of the desired day
|
||||
int daysOffset = (int)lastOfMonth.DayOfWeek - (int)DayOfWeek + 7;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using Server.Logging;
|
||||
|
||||
namespace Server.Engines.Events;
|
||||
|
||||
|
|
@ -11,7 +12,7 @@ public interface IRecurrencePattern
|
|||
/// Get the next occurrence of the event.
|
||||
/// <returns><c>DateTime</c> of the next occurence in UTC or DateTime.MaxValue</returns>
|
||||
/// </summary>
|
||||
DateTime GetNextOccurrence(DateTime afterUtc, TimeZoneInfo timeZone);
|
||||
DateTime GetNextOccurrence(DateTime afterUtc, TimeOnly time, TimeZoneInfo timeZone);
|
||||
}
|
||||
|
||||
public enum OrdinalDayOccurrence { Last = -1, First, Second, Third, Fourth, Fifth }
|
||||
|
|
@ -34,42 +35,47 @@ public abstract class ScheduledEvent
|
|||
{
|
||||
private static Serial _nextSerial = (Serial)1;
|
||||
|
||||
// Tie breaker for sorted set
|
||||
// Tie-breaker for sorted set
|
||||
public Serial Serial { get; }
|
||||
public IRecurrencePattern Recurrence { get; }
|
||||
public TimeZoneInfo TimeZone { get; }
|
||||
public TimeOnly Time { get; }
|
||||
public DateTime EndDate { get; }
|
||||
public DateTime NextOccurrence { get; private set; }
|
||||
|
||||
public ScheduledEvent(DateTime startOn, TimeZoneInfo timeZone = null)
|
||||
: this(startOn, startOn, null, timeZone)
|
||||
: this(startOn, startOn, TimeOnly.FromDateTime(startOn), null, timeZone)
|
||||
{
|
||||
}
|
||||
|
||||
public ScheduledEvent(DateTime afterUtc, IRecurrencePattern recurrence, TimeZoneInfo timeZone = null)
|
||||
: this(afterUtc, DateTime.MaxValue, recurrence, timeZone)
|
||||
public ScheduledEvent(DateTime startAfter, TimeOnly time, IRecurrencePattern recurrence, TimeZoneInfo timeZone = null)
|
||||
: this(startAfter, DateTime.MaxValue, time, recurrence, timeZone)
|
||||
{
|
||||
}
|
||||
|
||||
public ScheduledEvent(
|
||||
DateTime afterUtc,
|
||||
DateTime endDateUtc,
|
||||
DateTime startAfter,
|
||||
DateTime endOn,
|
||||
TimeOnly time,
|
||||
IRecurrencePattern recurrence,
|
||||
TimeZoneInfo timeZone = null
|
||||
)
|
||||
{
|
||||
Serial = _nextSerial++;
|
||||
Time = time;
|
||||
Recurrence = recurrence;
|
||||
TimeZone = timeZone ?? TimeZoneInfo.Utc;
|
||||
NextOccurrence = recurrence?.GetNextOccurrence(afterUtc, TimeZone) ?? afterUtc;
|
||||
EndDate = endDateUtc;
|
||||
|
||||
var afterUtc = startAfter.Kind == DateTimeKind.Utc ? startAfter : startAfter.LocalToUtc(TimeZone);
|
||||
NextOccurrence = recurrence?.GetNextOccurrence(afterUtc, time, TimeZone) ?? afterUtc;
|
||||
EndDate = endOn == DateTime.MaxValue || endOn.Kind == DateTimeKind.Utc ? endOn : endOn.LocalToUtc(TimeZone);
|
||||
}
|
||||
|
||||
public bool Advance()
|
||||
{
|
||||
OnEvent();
|
||||
|
||||
var next = Recurrence?.GetNextOccurrence(NextOccurrence, TimeZone) ?? DateTime.MaxValue;
|
||||
var next = Recurrence?.GetNextOccurrence(NextOccurrence, Time, TimeZone) ?? DateTime.MaxValue;
|
||||
if (next == DateTime.MaxValue || next > EndDate)
|
||||
{
|
||||
return false;
|
||||
|
|
@ -84,9 +90,11 @@ public abstract class ScheduledEvent
|
|||
|
||||
public class EventScheduler : Timer
|
||||
{
|
||||
private static readonly ILogger logger = LogFactory.GetLogger(typeof(EventScheduler));
|
||||
|
||||
private readonly SortedSet<ScheduledEvent> _schedule = new(ScheduledEventComparer.Default);
|
||||
|
||||
public static EventScheduler Instance { get; private set; }
|
||||
public static EventScheduler Shared { get; private set; }
|
||||
|
||||
public static IRecurrencePattern Hourly => new HourlyRecurrencePattern();
|
||||
|
||||
|
|
@ -118,32 +126,32 @@ public class EventScheduler : Timer
|
|||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static ScheduledEvent HourlyAt(DateTime startOn, Action action, TimeZoneInfo timeZone = null) =>
|
||||
Instance.ScheduleEvent(startOn, action, Hourly, timeZone);
|
||||
Shared.ScheduleEvent(startOn, action, Hourly, timeZone);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static ScheduledEvent DailyAt(DateTime startOn, Action action, TimeZoneInfo timeZone = null) =>
|
||||
Instance.ScheduleEvent(startOn, action, Daily, timeZone);
|
||||
Shared.ScheduleEvent(startOn, action, Daily, timeZone);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static ScheduledEvent WeeklyAt(DateTime startOn, Action action, TimeZoneInfo timeZone = null) =>
|
||||
Instance.ScheduleEvent(startOn, action, Weekly, timeZone);
|
||||
Shared.ScheduleEvent(startOn, action, Weekly, timeZone);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static ScheduledEvent BiweeklyAt(DateTime startOn, Action action, TimeZoneInfo timeZone = null) =>
|
||||
Instance.ScheduleEvent(startOn, action, Biweekly, timeZone);
|
||||
Shared.ScheduleEvent(startOn, action, Biweekly, timeZone);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static ScheduledEvent MonthlyAt(DateTime startOn, Action action, TimeZoneInfo timeZone = null) =>
|
||||
Instance.ScheduleEvent(startOn, action, GetMonthlyRecurrence(startOn.Day), timeZone);
|
||||
Shared.ScheduleEvent(startOn, action, GetMonthlyRecurrence(startOn.Day), timeZone);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static ScheduledEvent YearlyAt(DateTime startOn, Action action, TimeZoneInfo timeZone = null) =>
|
||||
Instance.ScheduleEvent(startOn, action, GetMonthlyRecurrence(startOn.Day), timeZone);
|
||||
Shared.ScheduleEvent(startOn, action, GetMonthlyRecurrence(startOn.Day), timeZone);
|
||||
|
||||
public static void Configure()
|
||||
{
|
||||
Instance ??= new EventScheduler();
|
||||
Instance.Start();
|
||||
Shared ??= new EventScheduler();
|
||||
Shared.Start();
|
||||
}
|
||||
|
||||
private EventScheduler() : base(TimeSpan.Zero, TimeSpan.FromSeconds(1.0))
|
||||
|
|
@ -151,20 +159,40 @@ public class EventScheduler : Timer
|
|||
}
|
||||
|
||||
public ScheduledEvent ScheduleEvent(
|
||||
DateTime afterUtc,
|
||||
DateTime startOn,
|
||||
Action callback,
|
||||
IRecurrencePattern recurrencePattern = null,
|
||||
TimeZoneInfo timeZone = null
|
||||
) => ScheduleEvent(startOn, TimeOnly.FromDateTime(startOn), callback, recurrencePattern, timeZone);
|
||||
|
||||
public ScheduledEvent ScheduleEvent(
|
||||
DateTime after,
|
||||
TimeOnly time,
|
||||
Action callback,
|
||||
IRecurrencePattern recurrencePattern = null,
|
||||
TimeZoneInfo timeZone = null
|
||||
)
|
||||
{
|
||||
var scheduledEvent = new CallbackScheduledEvent(afterUtc, callback, recurrencePattern, timeZone);
|
||||
var scheduledEvent = new CallbackScheduledEvent(after, time, callback, recurrencePattern, timeZone);
|
||||
ScheduleEvent(scheduledEvent);
|
||||
return scheduledEvent;
|
||||
}
|
||||
|
||||
public void ScheduleEvent(ScheduledEvent e) => _schedule.Add(e);
|
||||
public void ScheduleEvent(ScheduledEvent entry)
|
||||
{
|
||||
if (entry != null)
|
||||
{
|
||||
_schedule.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
public void StopEvent(ScheduledEvent entry) => _schedule.Remove(entry);
|
||||
public void StopEvent(ScheduledEvent entry)
|
||||
{
|
||||
if (entry != null)
|
||||
{
|
||||
_schedule.Remove(entry);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnTick()
|
||||
{
|
||||
|
|
@ -180,7 +208,18 @@ public class EventScheduler : Timer
|
|||
|
||||
_schedule.Remove(entry);
|
||||
|
||||
if (entry.Advance() && entry.NextOccurrence < DateTime.MaxValue)
|
||||
bool advance;
|
||||
try
|
||||
{
|
||||
advance = entry.Advance();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.Error(e, "Error while executing scheduled event.");
|
||||
advance = false;
|
||||
}
|
||||
|
||||
if (advance && entry.NextOccurrence < DateTime.MaxValue)
|
||||
{
|
||||
_schedule.Add(entry);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue