fix: Streamlines event scheduler API. Adds months to weekly recurrence (#2178)

This commit is contained in:
Kamron Batman 2025-05-07 22:42:44 -07:00 • committed by GitHub
parent 990e86b983
commit f95b3a8b3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 347 additions and 137 deletions

View file

@ -41,8 +41,8 @@ public class EventSchedulerTests
public int CallCount { get; private set; } public int CallCount { get; private set; }
public Action Callback { get; } public Action Callback { get; }
public TestScheduledEvent(DateTime startAfter, Action callback, IRecurrencePattern recurrence = null) public TestScheduledEvent(TimeOnly time, Action callback, IRecurrencePattern recurrence = null)
: base(startAfter, TimeOnly.FromDateTime(startAfter), recurrence, TimeZoneInfo.Utc) : base(time, recurrence)
{ {
CallCount = 0; CallCount = 0;
Callback = callback; Callback = callback;
@ -105,25 +105,28 @@ public class EventSchedulerTests
var executionOrder = new List<int>(); var executionOrder = new List<int>();
// Create events with staggered occurrences // Create events with staggered occurrences
var evt3 = new TestScheduledEvent( var evt1Start = Core._now.AddSeconds(10);
Core._now.AddSeconds(30),
() => executionOrder.Add(3)
);
var evt1 = new TestScheduledEvent( var evt1 = new TestScheduledEvent(
Core._now.AddSeconds(10), TimeOnly.FromDateTime(Core._now.AddSeconds(10)),
() => executionOrder.Add(1) () => executionOrder.Add(1)
); );
var evt2Start = Core._now.AddSeconds(20);
var evt2 = new TestScheduledEvent( var evt2 = new TestScheduledEvent(
Core._now.AddSeconds(20), TimeOnly.FromDateTime(evt2Start),
() => executionOrder.Add(2) () => executionOrder.Add(2)
); );
var evt3Start = Core._now.AddSeconds(30);
var evt3 = new TestScheduledEvent(
TimeOnly.FromDateTime(evt3Start),
() => executionOrder.Add(3)
);
// Add out of order // Add out of order
EventScheduler.Shared.ScheduleEvent(evt3); evt3.Schedule(evt3Start);
EventScheduler.Shared.ScheduleEvent(evt1); evt1.Schedule(evt1Start);
EventScheduler.Shared.ScheduleEvent(evt2); evt2.Schedule(evt2Start);
// Advance time to after all events // Advance time to after all events
Core._now = Core._now.AddSeconds(40); Core._now = Core._now.AddSeconds(40);
@ -157,12 +160,12 @@ public class EventSchedulerTests
var recurrence = new TestRecurrencePattern(TimeSpan.FromSeconds(10), 3); var recurrence = new TestRecurrencePattern(TimeSpan.FromSeconds(10), 3);
var evt = new TestScheduledEvent( var evt = new TestScheduledEvent(
Core._now, TimeOnly.FromDateTime(Core._now),
() => callCount++, () => callCount++,
recurrence recurrence
); );
EventScheduler.Shared.ScheduleEvent(evt); evt.Schedule(Core._now);
// Advance time to after all occurrences should have happened // Advance time to after all occurrences should have happened
Core._now = Core._now.AddSeconds(50); Core._now = Core._now.AddSeconds(50);
@ -271,14 +274,13 @@ public class EventSchedulerTests
var endTime = Core._now.AddSeconds(36); var endTime = Core._now.AddSeconds(36);
var customEvent = new CustomEvent( var customEvent = new CustomEvent(
startTime,
endTime,
TimeOnly.FromDateTime(startTime), TimeOnly.FromDateTime(startTime),
endTime,
recurrence, recurrence,
() => callCount++ () => callCount++
); );
EventScheduler.Shared.ScheduleEvent(customEvent); customEvent.Schedule(startTime);
Core._now = Core._now.AddSeconds(50); Core._now = Core._now.AddSeconds(50);
Timer.Slice(8); Timer.Slice(8);
@ -297,12 +299,11 @@ public class EventSchedulerTests
private readonly Action _callback; private readonly Action _callback;
public CustomEvent( public CustomEvent(
DateTime startAfter,
DateTime endOn,
TimeOnly time, TimeOnly time,
DateTime endOn,
IRecurrencePattern recurrence, IRecurrencePattern recurrence,
Action callback Action callback
) : base(startAfter, endOn, time, recurrence, TimeZoneInfo.Utc) => _callback = callback; ) : base(time, endOn, recurrence) => _callback = callback;
public override void OnEvent() public override void OnEvent()
{ {

View file

@ -51,6 +51,152 @@ public class RecurrencePatternTests
Assert.Equal(expected, result); Assert.Equal(expected, result);
} }
[Theory]
[InlineData(2024, 5, 15, 12, 30, 1, AllowedDays.All, 2024, 5, 16, 12, 30)] // Next day (Thursday)
[InlineData(2024, 5, 15, 12, 30, 1, AllowedDays.Monday, 2024, 5, 20, 12, 30)] // Next Monday
[InlineData(2024, 5, 15, 12, 30, 2, AllowedDays.Wednesday, 2024, 5, 29, 12, 30)] // Biweekly Wednesday
public void WeeklyRecurrencePattern_BasicPatterns_ReturnsCorrectDay(
int startYear, int startMonth, int startDay, int startHour, int startMinute,
int intervalWeeks, AllowedDays allowedDays,
int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute)
{
// Arrange
var tz = TimeZoneInfo.Utc;
var pattern = new WeeklyRecurrencePattern(intervalWeeks, AllowedMonths.All, allowedDays);
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, 12, 30, 12, 30, 1, AllowedDays.Monday, 2025, 1, 6, 12, 30)] // Across year boundary
[InlineData(2024, 5, 31, 12, 30, 1, AllowedDays.Saturday, 2024, 6, 1, 12, 30)] // Across month boundary
public void WeeklyRecurrencePattern_CrossBoundaries_ReturnsCorrectDay(
int startYear, int startMonth, int startDay, int startHour, int startMinute,
int intervalWeeks, AllowedDays allowedDays,
int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute)
{
// Arrange
var tz = TimeZoneInfo.Utc;
var pattern = new WeeklyRecurrencePattern(intervalWeeks, AllowedMonths.All, allowedDays);
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, 5, 15, 12, 30, AllowedMonths.January | AllowedMonths.February, AllowedDays.Wednesday, 2025, 1, 1, 12, 30)] // Skip to January
[InlineData(2024, 12, 15, 12, 30, AllowedMonths.January | AllowedMonths.December, AllowedDays.Wednesday, 2024, 12, 18, 12, 30)] // Same month
[InlineData(2024, 1, 31, 12, 30, AllowedMonths.February | AllowedMonths.April, AllowedDays.Saturday, 2024, 2, 3, 12, 30)] // Next month allowed
public void WeeklyRecurrencePattern_MonthFiltering_ReturnsCorrectDay(
int startYear, int startMonth, int startDay, int startHour, int startMinute,
AllowedMonths allowedMonths, AllowedDays allowedDays,
int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute)
{
// Arrange
var tz = TimeZoneInfo.Utc;
var pattern = new WeeklyRecurrencePattern(1, allowedMonths, allowedDays);
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("America/New_York", 2024, 3, 9, 12, 0, 1, AllowedDays.Sunday, 2024, 3, 10, 12, 0)] // Before spring forward
[InlineData("America/New_York", 2024, 3, 10, 2, 30, 1, AllowedDays.Sunday, 2024, 3, 17, 2, 30)] // During spring forward (invalid time)
[InlineData("America/New_York", 2024, 11, 2, 12, 0, 1, AllowedDays.Sunday, 2024, 11, 3, 12, 0)] // Before fall back
[InlineData("America/New_York", 2024, 11, 3, 1, 30, 1, AllowedDays.Sunday, 2024, 11, 10, 1, 30)] // During fall back (ambiguous time)
public void WeeklyRecurrencePattern_DSTTransitions_HandlesCorrectly(
string tzId, int startYear, int startMonth, int startDay, int startHour, int startMinute,
int intervalWeeks, AllowedDays allowedDays,
int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute)
{
// Arrange
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
var pattern = new WeeklyRecurrencePattern(intervalWeeks, AllowedMonths.All, allowedDays);
var startLocal = new DateTime(startYear, startMonth, startDay, startHour, startMinute, 0);
var afterUtc = startLocal.LocalToUtc(tz);
var time = new TimeOnly(expectedHour, expectedMinute);
// Act
var result = pattern.GetNextOccurrence(afterUtc, time, tz);
// Assert
var resultLocal = TimeZoneInfo.ConvertTimeFromUtc(result, tz);
Assert.Equal(new DateTime(expectedYear, expectedMonth, expectedDay, expectedHour, expectedMinute, 0), resultLocal);
}
[Theory]
[InlineData(2024, 5, 29, 12, 0, AllowedDays.None, 2024, 5, 29, 12, 30)] // Default to current day
[InlineData(2024, 5, 29, 12, 0, AllowedDays.Wednesday | AllowedDays.Friday, 2024, 5, 29, 12, 30)] // Current day is Wednesday
[InlineData(2024, 5, 29, 12, 0, AllowedDays.Monday | AllowedDays.Friday, 2024, 5, 31, 12, 30)] // Next allowed day (Friday)
public void WeeklyRecurrencePattern_DaysOfWeekHandling_ReturnsCorrectDay(
int startYear, int startMonth, int startDay, int startHour, int startMinute,
AllowedDays allowedDays,
int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute)
{
// Arrange
var tz = TimeZoneInfo.Utc;
var pattern = new WeeklyRecurrencePattern(1, AllowedMonths.All, allowedDays);
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, 1, 29, 12, 0, 1, 2024, 1, 29, 12, 30, AllowedMonths.January | AllowedMonths.March, 2024, 1, 30, 12, 30)] // Current month allowed
[InlineData(2024, 1, 31, 12, 0, 1, 2024, 1, 31, 12, 30, AllowedMonths.January | AllowedMonths.March, 2024, 3, 1, 12, 30)] // Skip February
public void WeeklyRecurrencePattern_WeekSpanningMonths_ReturnsCorrectDay(
int startYear, int startMonth, int startDay, int startHour, int startMinute,
int intervalWeeks,
int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute,
AllowedMonths allowedMonths,
int nextExpectedYear, int nextExpectedMonth, int nextExpectedDay, int nextExpectedHour, int nextExpectedMinute)
{
// Arrange
var tz = TimeZoneInfo.Utc;
var pattern = new WeeklyRecurrencePattern(intervalWeeks, allowedMonths);
var afterUtc = new DateTime(startYear, startMonth, startDay, startHour, startMinute, 0, DateTimeKind.Utc);
var time = new TimeOnly(expectedHour, expectedMinute);
// Act - first call should hit expectedDate
var result = pattern.GetNextOccurrence(afterUtc, time, tz);
// Assert
var expected = new DateTime(expectedYear, expectedMonth, expectedDay, expectedHour, expectedMinute, 0, DateTimeKind.Utc);
Assert.Equal(expected, result);
// Call again - should move to next available day in allowed months
var nextResult = pattern.GetNextOccurrence(result, time, tz);
var nextExpected = new DateTime(nextExpectedYear, nextExpectedMonth, nextExpectedDay, nextExpectedHour, nextExpectedMinute, 0, DateTimeKind.Utc);
Assert.Equal(nextExpected, nextResult);
}
[Theory] [Theory]
[InlineData(2024, 11, 3, 1, 30, DayOfWeek.Sunday, OrdinalDayOccurrence.First, "America/New_York", 2024, 11, 3)] [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.Last, "America/New_York", 2024, 3, 31)]
@ -145,7 +291,6 @@ public class RecurrencePatternTests
Assert.Equal(new DateTime(2024, 3, 31, 12, 0, 0), result); Assert.Equal(new DateTime(2024, 3, 31, 12, 0, 0), result);
} }
// Additional utility method tests
[Theory] [Theory]
[InlineData("America/New_York", 2024, 11, 3, 1, 30)] // Fall back - ambiguous time [InlineData("America/New_York", 2024, 11, 3, 1, 30)] // Fall back - ambiguous time
public void LocalToUtc_AmbiguousTime_HandlesConsistently( public void LocalToUtc_AmbiguousTime_HandlesConsistently(

View file

@ -0,0 +1,22 @@
using System;
namespace Server.Engines.Events;
[Flags]
public enum AllowedDays : byte
{
None = 0,
Sunday = 1 << 0,
Monday = 1 << 1,
Tuesday = 1 << 2,
Wednesday = 1 << 3,
Thursday = 1 << 4,
Friday = 1 << 5,
Saturday = 1 << 6,
All = Sunday | Monday | Tuesday | Wednesday | Thursday | Friday | Saturday
}
public static class DaysOfWeekExtension
{
public static AllowedDays ToDaysOfWeek(this DayOfWeek dayOfWeek) => (AllowedDays)(1 << (int)dayOfWeek);
}

View file

@ -0,0 +1,23 @@
using System;
namespace Server.Engines.Events;
[Flags]
public enum AllowedMonths
{
None = 0,
January = 1 << 0,
February = 1 << 1,
March = 1 << 2,
April = 1 << 3,
May = 1 << 4,
June = 1 << 5,
July = 1 << 6,
August = 1 << 7,
September = 1 << 8,
October = 1 << 9,
November = 1 << 10,
December = 1 << 11,
All = January | February | March | April | May | June | July | August | September | October | November | December
}

View file

@ -0,0 +1,67 @@
using System;
using System.Runtime.CompilerServices;
using Server.Logging;
namespace Server.Engines.Events;
public abstract class BaseScheduledEvent
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseScheduledEvent));
public TimeZoneInfo TimeZone { get; private set; } = TimeZoneInfo.Utc;
public DateTime NextOccurrence { get; private set; } = DateTime.MaxValue;
public bool Cancelled { get; private set; }
public EventScheduler Scheduler { get; private set; }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Schedule(DateTime startAfter, TimeZoneInfo timeZone = null) =>
Schedule(EventScheduler.Shared, startAfter, timeZone);
public void Schedule(EventScheduler scheduler, DateTime startAfter, TimeZoneInfo timeZone = null)
{
Cancel();
Scheduler = scheduler;
TimeZone = timeZone ?? TimeZoneInfo.Utc;
Schedule(startAfter, timeZone, true);
}
private void Schedule(DateTime startAfter, TimeZoneInfo timeZone, bool isFirst)
{
Cancelled = false;
var afterUtc = startAfter.Kind == DateTimeKind.Utc ? startAfter : startAfter.LocalToUtc(timeZone);
var next = GetNextOccurrence(afterUtc);
// For the first occurrence, we should set it to the startAfter date if we have no recurrence.
NextOccurrence = next == DateTime.MaxValue && isFirst ? afterUtc : next;
if (NextOccurrence != DateTime.MaxValue)
{
Scheduler.ScheduleEvent(this);
}
}
protected abstract DateTime GetNextOccurrence(DateTime after);
public void Cancel()
{
Cancelled = true;
Scheduler?.UnscheduleEvent(this);
}
public virtual void Advance()
{
try
{
OnEvent();
}
catch (Exception ex)
{
logger.Error(ex, "OnEvent failed to execute.");
}
Schedule(NextOccurrence, TimeZone, false);
}
public abstract void OnEvent();
}

View file

@ -7,23 +7,17 @@ public sealed class CallbackScheduledEvent : ScheduledEvent
private readonly Action _callback; private readonly Action _callback;
public CallbackScheduledEvent( public CallbackScheduledEvent(
DateTime after,
TimeOnly time, TimeOnly time,
Action callback, Action callback,
IRecurrencePattern recurrencePattern = null, IRecurrencePattern recurrencePattern = null
TimeZoneInfo timeZone = null ) : base(time, recurrencePattern) => _callback = callback ?? throw new ArgumentNullException(nameof(callback));
) : base(after, time, recurrencePattern, timeZone) =>
_callback = callback ?? throw new ArgumentNullException(nameof(callback));
public CallbackScheduledEvent( public CallbackScheduledEvent(
DateTime after,
DateTime endOn,
TimeOnly time, TimeOnly time,
DateTime endOn,
Action callback, Action callback,
IRecurrencePattern recurrencePattern = null, IRecurrencePattern recurrencePattern = null
TimeZoneInfo timeZone = null ) : base(time, endOn, recurrencePattern) => _callback = callback ?? throw new ArgumentNullException(nameof(callback));
) : base(after, endOn, time, recurrencePattern, timeZone) =>
_callback = callback ?? throw new ArgumentNullException(nameof(callback));
public override void OnEvent() => _callback(); public override void OnEvent() => _callback();
} }

View file

@ -35,32 +35,49 @@ public class DailyRecurrencePattern : IRecurrencePattern
public class WeeklyRecurrencePattern : IRecurrencePattern public class WeeklyRecurrencePattern : IRecurrencePattern
{ {
public int IntervalWeeks { get; } public int IntervalWeeks { get; }
public DaysOfWeek DaysOfWeek { get; } public AllowedDays AllowedDays { get; }
public AllowedMonths AllowedMonths { get; }
public WeeklyRecurrencePattern(int intervalWeeks = 1, DaysOfWeek daysOfWeek = DaysOfWeek.None) public WeeklyRecurrencePattern(int intervalWeeks = 1, AllowedMonths allowedMonths = AllowedMonths.All, AllowedDays allowedDays = AllowedDays.None)
{ {
IntervalWeeks = Math.Max(1, intervalWeeks); IntervalWeeks = Math.Max(1, intervalWeeks);
DaysOfWeek = daysOfWeek; AllowedDays = allowedDays == AllowedDays.None ? AllowedDays.All : allowedDays;
AllowedMonths = allowedMonths == AllowedMonths.None ? AllowedMonths.All : allowedMonths;
} }
public DateTime GetNextOccurrence(DateTime afterUtc, TimeOnly time, TimeZoneInfo timeZone) public DateTime GetNextOccurrence(DateTime afterUtc, TimeOnly time, TimeZoneInfo timeZone)
{ {
var local = TimeZoneInfo.ConvertTimeFromUtc(afterUtc, timeZone); var local = TimeZoneInfo.ConvertTimeFromUtc(afterUtc, timeZone);
var daysOfWeek = DaysOfWeek == DaysOfWeek.None var daysOfWeek = AllowedDays == AllowedDays.None ? local.DayOfWeek.ToDaysOfWeek() : AllowedDays;
? (DaysOfWeek)(1 << (int)local.DayOfWeek)
: DaysOfWeek;
var weekStart = local.Date.AddDays(-(int)local.DayOfWeek); var weekStart = local.Date.AddDays(-(int)local.DayOfWeek);
// No more days in this week, jump IntervalWeeks ahead // No more days in this week, jump IntervalWeeks ahead
for (int week = 0; week <= 100; week++) for (var week = 0; week <= 52; week++)
{ {
DateTime nextWeekStart = weekStart.AddDays(7 * IntervalWeeks * week); var nextWeekStart = weekStart.AddDays(7 * IntervalWeeks * week);
var weekEnd = nextWeekStart.AddDays(6);
for (int i = 0; i < 7; i++) var startMonth = (AllowedMonths)(1 << (nextWeekStart.Month - 1));
var endMonth = (AllowedMonths)(1 << (weekEnd.Month - 1));
// Skip the entire week if the start and end months are not in the allowed months
if ((AllowedMonths & (startMonth | endMonth)) == 0)
{
continue;
}
for (var i = 0; i < 7; i++)
{ {
var day = nextWeekStart.AddDays(i); var day = nextWeekStart.AddDays(i);
var dayOfWeekFlag = (DaysOfWeek)(1 << (int)day.DayOfWeek);
var currentMonth = (AllowedMonths)(1 << (day.Month - 1));
if ((AllowedMonths & currentMonth) == 0)
{
continue;
}
var dayOfWeekFlag = (AllowedDays)(1 << (int)day.DayOfWeek);
if ((daysOfWeek & dayOfWeekFlag) != 0) if ((daysOfWeek & dayOfWeekFlag) != 0)
{ {
var candidate = new DateTime(day.Year, day.Month, day.Day, time.Hour, time.Minute, 0); var candidate = new DateTime(day.Year, day.Month, day.Day, time.Hour, time.Minute, 0);
@ -94,7 +111,7 @@ public class MonthlyRecurrencePattern : IRecurrencePattern
var month = local.Month; var month = local.Month;
var day = DayOfMonth == -1 ? local.Day : DayOfMonth; var day = DayOfMonth == -1 ? local.Day : DayOfMonth;
for (int i = 0; i < 100; i++) for (var i = 0; i < 100; i++)
{ {
var nextMonth = month + IntervalMonths * i; var nextMonth = month + IntervalMonths * i;
var candidate = new DateTime(year, 1, 1) var candidate = new DateTime(year, 1, 1)
@ -132,7 +149,7 @@ public class MonthlyOrdinalRecurrencePattern : IRecurrencePattern
var year = local.Year; var year = local.Year;
var month = local.Month; var month = local.Month;
for (int i = 0; i < 100; i++) for (var i = 0; i < 100; i++)
{ {
var nextMonth = month + IntervalMonths * i; var nextMonth = month + IntervalMonths * i;
var candidateYearOffset = Math.DivRem(nextMonth - 1, 12, out var candidateMonthOffset); var candidateYearOffset = Math.DivRem(nextMonth - 1, 12, out var candidateMonthOffset);
@ -147,7 +164,7 @@ public class MonthlyOrdinalRecurrencePattern : IRecurrencePattern
.Add(time.ToTimeSpan()); .Add(time.ToTimeSpan());
// Find the first occurrence of the desired day // Find the first occurrence of the desired day
int daysOffset = ((int)DayOfWeek - (int)firstOfMonth.DayOfWeek + 7) % 7; var daysOffset = ((int)DayOfWeek - (int)firstOfMonth.DayOfWeek + 7) % 7;
if (daysOffset > 7) if (daysOffset > 7)
{ {
daysOffset -= 7; daysOffset -= 7;
@ -168,7 +185,7 @@ public class MonthlyOrdinalRecurrencePattern : IRecurrencePattern
.Add(time.ToTimeSpan()); .Add(time.ToTimeSpan());
// Find the last occurrence of the desired day // Find the last occurrence of the desired day
int daysOffset = (int)lastOfMonth.DayOfWeek - (int)DayOfWeek + 7; var daysOffset = (int)lastOfMonth.DayOfWeek - (int)DayOfWeek + 7;
if (daysOffset >= 7) if (daysOffset >= 7)
{ {
daysOffset -= 7; daysOffset -= 7;

View file

@ -2,7 +2,6 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using Server.Logging;
namespace Server.Engines.Events; namespace Server.Engines.Events;
@ -17,25 +16,9 @@ public interface IRecurrencePattern
public enum OrdinalDayOccurrence { Last = -1, First, Second, Third, Fourth, Fifth } public enum OrdinalDayOccurrence { Last = -1, First, Second, Third, Fourth, Fifth }
[Flags]
public enum DaysOfWeek : byte
{
None = 0,
Sunday = 1,
Monday = 2,
Tuesday = 4,
Wednesday = 8,
Thursday = 16,
Friday = 32,
Saturday = 64,
EveryDay = Sunday | Monday | Tuesday | Wednesday | Thursday | Friday | Saturday
}
public class EventScheduler : Timer public class EventScheduler : Timer
{ {
private static readonly ILogger logger = LogFactory.GetLogger(typeof(EventScheduler)); private readonly PriorityQueue<BaseScheduledEvent, DateTime> _schedule = new();
private readonly PriorityQueue<ScheduledEvent, DateTime> _schedule = new();
public static EventScheduler Shared { get; private set; } public static EventScheduler Shared { get; private set; }
@ -116,16 +99,24 @@ public class EventScheduler : Timer
TimeZoneInfo timeZone = null TimeZoneInfo timeZone = null
) )
{ {
var scheduledEvent = new CallbackScheduledEvent(after, time, callback, recurrencePattern, timeZone); var scheduledEvent = new CallbackScheduledEvent(time, callback, recurrencePattern);
ScheduleEvent(scheduledEvent); scheduledEvent.Schedule(this, after, timeZone);
return scheduledEvent; return scheduledEvent;
} }
public void ScheduleEvent(ScheduledEvent entry) public void ScheduleEvent(BaseScheduledEvent entry)
{
if (entry != null && entry.NextOccurrence < DateTime.MaxValue)
{
_schedule.Enqueue(entry, entry.NextOccurrence);
}
}
public void UnscheduleEvent(BaseScheduledEvent entry)
{ {
if (entry != null) if (entry != null)
{ {
_schedule.Enqueue(entry, entry.NextOccurrence); _schedule.Remove(entry, out _, out _);
} }
} }
@ -149,21 +140,7 @@ public class EventScheduler : Timer
continue; continue;
} }
DateTime nextOccurrence; entry.Advance(); // Advances the event and self queues if necessary
try
{
nextOccurrence = entry.Advance();
}
catch (Exception e)
{
logger.Error(e, "Error while executing scheduled event.");
nextOccurrence = DateTime.MaxValue;
}
if (nextOccurrence < DateTime.MaxValue)
{
_schedule.Enqueue(entry, nextOccurrence);
}
} }
} }
} }

View file

@ -2,59 +2,31 @@ using System;
namespace Server.Engines.Events; namespace Server.Engines.Events;
public abstract class ScheduledEvent public abstract class ScheduledEvent : BaseScheduledEvent
{ {
public IRecurrencePattern Recurrence { get; } public IRecurrencePattern Recurrence { get; }
public TimeZoneInfo TimeZone { get; }
public TimeOnly Time { get; } public TimeOnly Time { get; }
public DateTime EndDate { get; } public DateTime EndDate { get; }
public DateTime NextOccurrence { get; protected set; }
public bool Cancelled { get; private set; }
public ScheduledEvent(DateTime startOn, TimeZoneInfo timeZone = null) public ScheduledEvent(TimeOnly time, IRecurrencePattern recurrence = null)
: this(startOn, startOn, TimeOnly.FromDateTime(startOn), null, timeZone) : this(time, DateTime.MaxValue, recurrence)
{
}
public ScheduledEvent(DateTime startAfter, TimeOnly time, IRecurrencePattern recurrence, TimeZoneInfo timeZone = null)
: this(startAfter, DateTime.MaxValue, time, recurrence, timeZone)
{ {
} }
public ScheduledEvent( public ScheduledEvent(
DateTime startAfter,
DateTime endOn,
TimeOnly time, TimeOnly time,
IRecurrencePattern recurrence, DateTime endOn,
TimeZoneInfo timeZone = null IRecurrencePattern recurrence = null
) )
{ {
Time = time; Time = time;
Recurrence = recurrence; Recurrence = recurrence;
TimeZone = timeZone ?? TimeZoneInfo.Utc;
EndDate = endOn == DateTime.MaxValue || endOn.Kind == DateTimeKind.Utc ? endOn : endOn.LocalToUtc(TimeZone); EndDate = endOn == DateTime.MaxValue || endOn.Kind == DateTimeKind.Utc ? endOn : endOn.LocalToUtc(TimeZone);
var afterUtc = startAfter.Kind == DateTimeKind.Utc ? startAfter : startAfter.LocalToUtc(TimeZone);
var next = GetOccurrence(afterUtc);
// For the first occurrence, we should set it to the startAfter date if we have no recurrence.
NextOccurrence = next == DateTime.MaxValue ? afterUtc : next;
} }
protected virtual DateTime GetOccurrence(DateTime after) protected override DateTime GetNextOccurrence(DateTime after)
{ {
var next = Recurrence?.GetNextOccurrence(after, Time, TimeZone) ?? DateTime.MaxValue; var next = Recurrence?.GetNextOccurrence(after, Time, TimeZone) ?? DateTime.MaxValue;
return next >= EndDate ? DateTime.MaxValue : next; return next >= EndDate ? DateTime.MaxValue : next;
} }
public void Cancel() => Cancelled = true;
public virtual DateTime Advance()
{
OnEvent();
return NextOccurrence = GetOccurrence(NextOccurrence);
}
public abstract void OnEvent();
} }

View file

@ -7,27 +7,23 @@ public class YearlyCallbackScheduledEvent : YearlyScheduledEvent
private readonly Action _callback; private readonly Action _callback;
protected YearlyCallbackScheduledEvent( protected YearlyCallbackScheduledEvent(
DateTime startAfter,
TimeOnly time, TimeOnly time,
MonthDay yearlyStart, MonthDay yearlyStart,
MonthDay yearlyEnd, MonthDay yearlyEnd,
Action callback, Action callback,
IRecurrencePattern recurrence, IRecurrencePattern recurrence
TimeZoneInfo timeZone = null ) : this(time, yearlyStart, yearlyEnd, DateTime.MaxValue, callback, recurrence)
) : this(startAfter, DateTime.MaxValue, time, yearlyStart, yearlyEnd, callback, recurrence, timeZone)
{ {
} }
protected YearlyCallbackScheduledEvent( protected YearlyCallbackScheduledEvent(
DateTime startAfter,
DateTime endOn,
TimeOnly time, TimeOnly time,
MonthDay yearlyStart, MonthDay yearlyStart,
MonthDay yearlyEnd, MonthDay yearlyEnd,
DateTime endOn,
Action callback, Action callback,
IRecurrencePattern recurrence, IRecurrencePattern recurrence
TimeZoneInfo timeZone = null ) : base(time, yearlyStart, yearlyEnd, endOn, recurrence) => _callback = callback;
) : base(startAfter, endOn, time, yearlyStart, yearlyEnd, recurrence, timeZone) => _callback = callback;
public override void OnEvent() => _callback(); public override void OnEvent() => _callback();
} }

View file

@ -9,33 +9,29 @@ public abstract class YearlyScheduledEvent : ScheduledEvent
public MonthDay YearlyEnd { get; } public MonthDay YearlyEnd { get; }
protected YearlyScheduledEvent( protected YearlyScheduledEvent(
DateTime startAfter,
TimeOnly time, TimeOnly time,
MonthDay yearlyStart, MonthDay yearlyStart,
MonthDay yearlyEnd, MonthDay yearlyEnd,
IRecurrencePattern recurrence, IRecurrencePattern recurrence
TimeZoneInfo timeZone = null ) : this( time, yearlyStart, yearlyEnd, DateTime.MaxValue, recurrence)
) : this(startAfter, DateTime.MaxValue, time, yearlyStart, yearlyEnd, recurrence, timeZone)
{ {
} }
protected YearlyScheduledEvent( protected YearlyScheduledEvent(
DateTime startAfter,
DateTime endOn,
TimeOnly time, TimeOnly time,
MonthDay yearlyStart, MonthDay yearlyStart,
MonthDay yearlyEnd, MonthDay yearlyEnd,
IRecurrencePattern recurrence, DateTime endOn,
TimeZoneInfo timeZone = null IRecurrencePattern recurrence
) : base(startAfter, endOn, time, recurrence, timeZone) ) : base(time, endOn, recurrence)
{ {
YearlyStart = yearlyStart; YearlyStart = yearlyStart;
YearlyEnd = yearlyEnd; YearlyEnd = yearlyEnd;
} }
protected override DateTime GetOccurrence(DateTime after) protected override DateTime GetNextOccurrence(DateTime after)
{ {
var next = base.GetOccurrence(after); var next = base.GetNextOccurrence(after);
if (next == DateTime.MaxValue) if (next == DateTime.MaxValue)
{ {