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:
parent
4e25c74cdf
commit
78feea86b4
9 changed files with 686 additions and 96 deletions
2
.github/workflows/build-test.yml
vendored
2
.github/workflows/build-test.yml
vendored
|
|
@ -81,7 +81,7 @@ jobs:
|
|||
run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel
|
||||
if: ${{ matrix.packageManager == 'dnf' }}
|
||||
- name: Install Prerequisites using apt
|
||||
run: apt-get update -y && apt-get install -y curl libicu-dev libdeflate-dev zstd libargon2-dev
|
||||
run: apt-get update -y && apt-get install -y curl libicu-dev libdeflate-dev zstd libargon2-dev tzdata
|
||||
if: ${{ matrix.packageManager == 'apt' }}
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -102,7 +102,8 @@ public static class Core
|
|||
|
||||
private static long _tickCount;
|
||||
|
||||
private static DateTime _now;
|
||||
// Make this available to unit tests for mocking
|
||||
internal static DateTime _now;
|
||||
|
||||
public static long TickCount => _tickCount;
|
||||
|
||||
|
|
|
|||
|
|
@ -47,5 +47,8 @@
|
|||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>Server.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>UOContent.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1468,4 +1468,21 @@ public static partial class Utility
|
|||
table.Remove(key);
|
||||
table.Add(key, value);
|
||||
}
|
||||
|
||||
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);
|
||||
return DateTime.SpecifyKind(local - offsets[1], DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
return DateTime.SpecifyKind(local - tz.GetUtcOffset(local), DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,280 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server;
|
||||
using Server.Engines.Events;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests;
|
||||
|
||||
[Collection("Sequential Tests")]
|
||||
public class EventSchedulerTests
|
||||
{
|
||||
private static void Init()
|
||||
{
|
||||
Core._now = DateTime.UtcNow;
|
||||
Timer.Init(0);
|
||||
EventScheduler.Configure();
|
||||
}
|
||||
|
||||
private static void Finish()
|
||||
{
|
||||
EventScheduler.Instance.Stop();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScheduleEvent_ExecutesCallback_HappyPath()
|
||||
{
|
||||
Init();
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
}
|
||||
finally
|
||||
{
|
||||
bool called = false;
|
||||
var evt = EventScheduler.Instance.ScheduleEvent(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);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Finish();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScheduleEvent_NullCallback_Throws()
|
||||
{
|
||||
Init();
|
||||
|
||||
try
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
EventScheduler.Instance.ScheduleEvent(Core._now, null)
|
||||
);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Finish();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdvanceEvent_PastEndDate_DoesNotReschedule()
|
||||
{
|
||||
Init();
|
||||
|
||||
try
|
||||
{
|
||||
bool called = false;
|
||||
var evt = new CallbackScheduledEvent(Core._now, Core._now, () => called = true);
|
||||
|
||||
EventScheduler.Instance.ScheduleEvent(evt);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Finish();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalToUtc_EdgeCases_BeforeAndAfterTransition()
|
||||
{
|
||||
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);
|
||||
|
||||
var beforeUtc = beforeSpring.LocalToUtc(tz);
|
||||
var afterUtc = afterSpring.LocalToUtc(tz);
|
||||
|
||||
// Should be 1 hour + 2 minutes apart in UTC (not 1 hour 2 minutes)
|
||||
Assert.Equal(2, (afterUtc - beforeUtc).TotalMinutes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}";
|
||||
}
|
||||
}
|
||||
28
Projects/UOContent/Engines/Events/CallbackScheduledEvent.cs
Normal file
28
Projects/UOContent/Engines/Events/CallbackScheduledEvent.cs
Normal 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();
|
||||
}
|
||||
171
Projects/UOContent/Engines/Events/CommonRecurrencePatterns.cs
Normal file
171
Projects/UOContent/Engines/Events/CommonRecurrencePatterns.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue