feat: Adds scheduler with wallclock timer (#2163)

### Summary

* Adds an event scheduler.
* Adds conveniences for hourly, daily, weekly, biweekly, monthly, ordinal monthly, and yearly recurrences

Example:

```cs
var tz = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");

// Specify the time of the day, and the day of the week you want it to occur. Make sure it is translated into Utc.
// The next occurrence will be _after_ the specified date/time.
var scheduledEvent = EventScheduler.WeeklyAt(new DateTime(2025, 04, 26, 17, 00, 00), StartEvent, tz);

void StartEvent()
{
    World.Broadcast(0x30, false, "The event has started!");
}

Console.WriteLine("Event starts on {0}", scheduledEvent.NextOccurrence);
```

In this example, on _Saturday, May 3rd, 2025 @ 5pm ET_, the message "The event has started!" will be broadcasted.
This commit is contained in:
Kamron Batman 2025-04-26 22:27:08 -07:00 • committed by GitHub
parent 4e25c74cdf
commit 78feea86b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 686 additions and 96 deletions

View file

@ -1,30 +0,0 @@
namespace Server.Engines.Events
{
public class BroadcastEvent : IEvent
{
private readonly int _hue;
private readonly string _text;
public BroadcastEvent(int hue, string text)
{
_hue = hue;
_text = text;
}
public void OnEventScheduled()
{
World.Broadcast(_hue, true, _text);
}
public static void Initialize()
{
/*
EventScheduler.Instance.ScheduleEvent(new BroadcastEvent(22, "Test Message Please Ignore 2min"), 0, 0, TimeSpan.FromMinutes(2.0));
EventScheduler.Instance.ScheduleEvent(new BroadcastEvent(33, "Test Message Please Ignore 3min"), 0, 0, TimeSpan.FromMinutes(3.0));
EventScheduler.Instance.ScheduleEvent(new BroadcastEvent(44, "Test Message Please Ignore 4min"), 0, 0, TimeSpan.FromMinutes(4.0));
*/
}
public override string ToString() => $"Broadcast: {_text}";
}
}

View file

@ -0,0 +1,28 @@
using System;
namespace Server.Engines.Events;
public sealed class CallbackScheduledEvent : ScheduledEvent
{
private readonly Action _callback;
public CallbackScheduledEvent(
DateTime afterUtc,
Action callback,
IRecurrencePattern recurrencePattern = null,
TimeZoneInfo timeZone = null
) : base(afterUtc, recurrencePattern, timeZone) =>
_callback = callback ?? throw new ArgumentNullException(nameof(callback));
public CallbackScheduledEvent(
DateTime afterUtc,
DateTime endDate,
Action callback,
IRecurrencePattern recurrencePattern = null,
TimeZoneInfo timeZone = null
) : base(afterUtc, endDate, recurrencePattern, timeZone) =>
_callback = callback ?? throw new ArgumentNullException(nameof(callback));
public override void OnEvent() => _callback();
}

View file

@ -0,0 +1,171 @@
namespace Server.Engines.Events;
using System;
public class HourlyRecurrencePattern : IRecurrencePattern
{
public int IntervalHours { get; }
public HourlyRecurrencePattern(int intervalHours = 1) => IntervalHours = Math.Max(1, intervalHours);
public DateTime GetNextOccurrence(DateTime afterUtc, TimeZoneInfo timeZone) => afterUtc.AddHours(IntervalHours);
}
public class DailyRecurrencePattern : IRecurrencePattern
{
public int IntervalDays { get; }
public DailyRecurrencePattern(int intervalDays = 1) => IntervalDays = Math.Max(1, intervalDays);
public DateTime GetNextOccurrence(DateTime afterUtc, TimeZoneInfo timeZone) => afterUtc.AddDays(IntervalDays);
}
public class WeeklyRecurrencePattern : IRecurrencePattern
{
public int IntervalWeeks { get; }
public DaysOfWeek DaysOfWeek { get; }
public WeeklyRecurrencePattern(int intervalWeeks = 1, DaysOfWeek daysOfWeek = DaysOfWeek.None)
{
IntervalWeeks = Math.Max(1, intervalWeeks);
DaysOfWeek = daysOfWeek;
}
public DateTime GetNextOccurrence(DateTime afterUtc, TimeZoneInfo timeZone)
{
var local = TimeZoneInfo.ConvertTimeFromUtc(afterUtc, timeZone);
var weekStart = local.Date.AddDays(1);
var daysOfWeek = DaysOfWeek;
// Set the day of the week to whatever day it is now
if (daysOfWeek == DaysOfWeek.None)
{
daysOfWeek = (DaysOfWeek)(1 << (int)local.DayOfWeek);
}
// 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++)
{
var candidate = weekStart.AddDays(i);
var candidateDay = (DaysOfWeek)(1 << (int)candidate.DayOfWeek);
if ((daysOfWeek & candidateDay) != 0 && candidate > local && !timeZone.IsInvalidTime(candidate))
{
return candidate.LocalToUtc(timeZone);
}
}
return DateTime.MaxValue;
}
}
public class MonthlyRecurrencePattern : IRecurrencePattern
{
public int DayOfMonth { get; }
public int IntervalMonths { get; }
public MonthlyRecurrencePattern(int dayOfMonth = -1, int intervalMonths = 1)
{
DayOfMonth = dayOfMonth != -1 ? Math.Clamp(dayOfMonth, 1, 31) : -1;
IntervalMonths = Math.Max(1, intervalMonths);
}
public DateTime GetNextOccurrence(DateTime afterUtc, 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++)
{
var nextMonth = month + IntervalMonths * i;
var candidate = new DateTime(year, 1, 1)
.AddMonths(nextMonth - 1)
.AddDays(day - 1)
.Add(time);
// 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))
{
return candidate.LocalToUtc(timeZone);
}
}
return DateTime.MaxValue;
}
}
public class MonthlyOrdinalRecurrencePattern : IRecurrencePattern
{
public int IntervalMonths { get; }
public OrdinalDayOccurrence Ordinal { get; }
public DayOfWeek DayOfWeek { get; }
public MonthlyOrdinalRecurrencePattern(OrdinalDayOccurrence ordinal, DayOfWeek dayOfWeek, int intervalMonths = 1)
{
IntervalMonths = Math.Max(1, intervalMonths);
Ordinal = ordinal;
DayOfWeek = dayOfWeek;
}
public DateTime GetNextOccurrence(DateTime afterUtc, TimeZoneInfo timeZone)
{
var local = TimeZoneInfo.ConvertTimeFromUtc(afterUtc, timeZone);
var year = local.Year;
var month = local.Month;
for (int i = 0; i < 100; i++)
{
var nextMonth = month + IntervalMonths * i;
var candidateYearOffset = Math.DivRem(nextMonth - 1, 12, out var candidateMonthOffset);
var candidateYear = year + candidateYearOffset;
var candidateMonth = candidateMonthOffset + 1;
DateTime candidate;
if (Ordinal >= OrdinalDayOccurrence.First)
{
// Find the first day of the month
var firstOfMonth = new DateTime(candidateYear, candidateMonth, 1, local.Hour, local.Minute, local.Second);
// Find the first occurrence of the desired day
int daysOffset = ((int)DayOfWeek - (int)firstOfMonth.DayOfWeek + 7) % 7;
if (daysOffset > 7)
{
daysOffset -= 7;
}
candidate = firstOfMonth.AddDays(daysOffset + 7 * (int)Ordinal);
// If candidate is not in the same month, skip
if (candidate.Month != candidateMonth)
{
continue;
}
}
else
{
// 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);
// Find the last occurrence of the desired day
int daysOffset = (int)lastOfMonth.DayOfWeek - (int)DayOfWeek + 7;
if (daysOffset >= 7)
{
daysOffset -= 7;
}
candidate = lastOfMonth.AddDays(-daysOffset);
}
if (candidate > local && !timeZone.IsInvalidTime(candidate))
{
return candidate.LocalToUtc(timeZone);
}
}
return DateTime.MaxValue;
}
}

View file

@ -1,95 +1,215 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace Server.Engines.Events
namespace Server.Engines.Events;
public interface IRecurrencePattern
{
public interface IEvent
/// <summary>
/// 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);
}
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 abstract class ScheduledEvent
{
private static Serial _nextSerial = (Serial)1;
// Tie breaker for sorted set
public Serial Serial { get; }
public IRecurrencePattern Recurrence { get; }
public TimeZoneInfo TimeZone { get; }
public DateTime EndDate { get; }
public DateTime NextOccurrence { get; private set; }
public ScheduledEvent(DateTime startOn, TimeZoneInfo timeZone = null)
: this(startOn, startOn, null, timeZone)
{
void OnEventScheduled();
}
public class EventScheduleEntry
public ScheduledEvent(DateTime afterUtc, IRecurrencePattern recurrence, TimeZoneInfo timeZone = null)
: this(afterUtc, DateTime.MaxValue, recurrence, timeZone)
{
private readonly IEvent _event;
private TimeSpan _offset;
public EventScheduleEntry(IEvent e, DateTime firstSpawn, TimeSpan interval, TimeSpan offset)
{
_offset = offset;
_event = e;
Interval = interval;
NextOccurrence = firstSpawn;
}
public DateTime NextOccurrence { get; private set; }
public TimeSpan Interval { get; }
public void Occur()
{
NextOccurrence += Interval;
_event?.OnEventScheduled();
}
public override string ToString() => _event?.ToString();
}
public class EventScheduler : Timer
public ScheduledEvent(
DateTime afterUtc,
DateTime endDateUtc,
IRecurrencePattern recurrence,
TimeZoneInfo timeZone = null
)
{
private static EventScheduler _instance;
private readonly List<EventScheduleEntry> _schedule = new();
Serial = _nextSerial++;
Recurrence = recurrence;
TimeZone = timeZone ?? TimeZoneInfo.Utc;
NextOccurrence = recurrence?.GetNextOccurrence(afterUtc, TimeZone) ?? afterUtc;
EndDate = endDateUtc;
}
private EventScheduler() : base(TimeSpan.Zero, TimeSpan.FromSeconds(1.0))
public bool Advance()
{
OnEvent();
var next = Recurrence?.GetNextOccurrence(NextOccurrence, TimeZone) ?? DateTime.MaxValue;
if (next == DateTime.MaxValue || next > EndDate)
{
return false;
}
public static EventScheduler Instance => _instance ??= new EventScheduler();
public static List<IEvent> AvailableEvents { get; } = new();
NextOccurrence = next;
return true;
}
public static void Initialize()
public abstract void OnEvent();
}
public class EventScheduler : Timer
{
private readonly SortedSet<ScheduledEvent> _schedule = new(ScheduledEventComparer.Default);
public static EventScheduler Instance { get; private set; }
public static IRecurrencePattern Hourly => new HourlyRecurrencePattern();
public static IRecurrencePattern Daily => new DailyRecurrencePattern();
// Recur every week, on the same day/time as the first occurence
public static IRecurrencePattern Weekly => new WeeklyRecurrencePattern();
// Recur every two weeks, on the same day/time as the first occurence
public static IRecurrencePattern Biweekly => new WeeklyRecurrencePattern(2);
public static IRecurrencePattern Monthly => new MonthlyRecurrencePattern();
public static IRecurrencePattern Yearly => new MonthlyRecurrencePattern(-1, 12);
// For each of the days of the week
private static readonly Dictionary<int, MonthlyRecurrencePattern> _monthlyRecurrenceByDay = [];
public static IRecurrencePattern GetMonthlyRecurrence(int dayOfMonth)
{
ref var pattern = ref CollectionsMarshal.GetValueRefOrAddDefault(_monthlyRecurrenceByDay, dayOfMonth, out var exists);
if (!exists)
{
Instance.Start();
pattern = new MonthlyRecurrencePattern(dayOfMonth);
}
public void ScheduleEvent(IEvent e, int hour, int min)
{
ScheduleEvent(e, hour, min, TimeSpan.FromDays(1.0));
}
return pattern;
}
public void ScheduleEvent(IEvent e, int hour, int min, TimeSpan interval)
{
var now = Core.Now;
var firstRun = new DateTime(now.Year, now.Month, now.Day, hour, min, 0);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ScheduledEvent HourlyAt(DateTime startOn, Action action, TimeZoneInfo timeZone = null) =>
Instance.ScheduleEvent(startOn, action, Hourly, timeZone);
while (now > firstRun)
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ScheduledEvent DailyAt(DateTime startOn, Action action, TimeZoneInfo timeZone = null) =>
Instance.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);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ScheduledEvent BiweeklyAt(DateTime startOn, Action action, TimeZoneInfo timeZone = null) =>
Instance.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);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ScheduledEvent YearlyAt(DateTime startOn, Action action, TimeZoneInfo timeZone = null) =>
Instance.ScheduleEvent(startOn, action, GetMonthlyRecurrence(startOn.Day), timeZone);
public static void Configure()
{
Instance ??= new EventScheduler();
Instance.Start();
}
private EventScheduler() : base(TimeSpan.Zero, TimeSpan.FromSeconds(1.0))
{
}
public ScheduledEvent ScheduleEvent(
DateTime afterUtc,
Action callback,
IRecurrencePattern recurrencePattern = null,
TimeZoneInfo timeZone = null
)
{
var scheduledEvent = new CallbackScheduledEvent(afterUtc, callback, recurrencePattern, timeZone);
ScheduleEvent(scheduledEvent);
return scheduledEvent;
}
public void ScheduleEvent(ScheduledEvent e) => _schedule.Add(e);
public void StopEvent(ScheduledEvent entry) => _schedule.Remove(entry);
protected override void OnTick()
{
var now = Core.Now;
while (_schedule.Count > 0)
{
var entry = _schedule.Min!;
if (entry.NextOccurrence > now)
{
firstRun += interval;
break;
}
ScheduleEvent(
new EventScheduleEntry(e, firstRun, interval, TimeSpan.FromHours(hour) + TimeSpan.FromMinutes(min))
);
}
public void ScheduleEvent(EventScheduleEntry e)
{
_schedule.Add(e);
}
public void RemoveEvent(EventScheduleEntry entry)
{
_schedule.Remove(entry);
}
protected override void OnTick()
{
foreach (var entry in _schedule)
if (entry.Advance() && entry.NextOccurrence < DateTime.MaxValue)
{
if (entry.NextOccurrence <= Core.Now)
{
entry.Occur();
}
_schedule.Add(entry);
}
}
}
private sealed class ScheduledEventComparer : IComparer<ScheduledEvent>
{
public static readonly ScheduledEventComparer Default = new();
public int Compare(ScheduledEvent x, ScheduledEvent y)
{
if (x == null && y == null)
{
return 0;
}
if (x == null)
{
return 1;
}
if (y == null)
{
return -1;
}
var next = x.NextOccurrence.CompareTo(y.NextOccurrence);
return next != 0 ? next : x.Serial.CompareTo(y.Serial);
}
}
}