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:
Kamron Batman 2025-04-28 16:16:33 -07:00 • committed by GitHub
parent f89b2be653
commit 21a4092dd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 495 additions and 250 deletions

View file

@ -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();
}

View file

@ -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;

View file

@ -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);
}