From 21a4092dd826a5d20afa95908caa653fe84810f4 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:16:33 -0700 Subject: [PATCH] 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 ); ``` --- .github/workflows/code_quality.yml | 2 +- Projects/Server/Utilities/Utility.cs | 6 - .../Engines/Events/EventSchedulerTests.cs | 432 ++++++++++-------- .../Engines/Events/RecurrencePatternTests.cs | 168 +++++++ .../Engines/Events/CallbackScheduledEvent.cs | 13 +- .../Events/CommonRecurrencePatterns.cs | 37 +- .../Engines/Events/EventScheduler.cs | 87 +++- 7 files changed, 495 insertions(+), 250 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Engines/Events/RecurrencePatternTests.cs diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml index ae9f3e078..e7bbc2ebe 100644 --- a/.github/workflows/code_quality.yml +++ b/.github/workflows/code_quality.yml @@ -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 }} diff --git a/Projects/Server/Utilities/Utility.cs b/Projects/Server/Utilities/Utility.cs index 50dd43de7..6ef4aa72c 100644 --- a/Projects/Server/Utilities/Utility.cs +++ b/Projects/Server/Utilities/Utility.cs @@ -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); diff --git a/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs b/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs index fe5d0c280..d44116a3b 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs @@ -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(() => - EventScheduler.Instance.ScheduleEvent(Core._now, null) + var executionOrder = new List(); + + // 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 ?? [] - ); - } - 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(); + } + } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Events/RecurrencePatternTests.cs b/Projects/UOContent.Tests/Tests/Engines/Events/RecurrencePatternTests.cs new file mode 100644 index 000000000..7917194c8 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Events/RecurrencePatternTests.cs @@ -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); + } +} diff --git a/Projects/UOContent/Engines/Events/CallbackScheduledEvent.cs b/Projects/UOContent/Engines/Events/CallbackScheduledEvent.cs index 1034314f3..e142cf247 100644 --- a/Projects/UOContent/Engines/Events/CallbackScheduledEvent.cs +++ b/Projects/UOContent/Engines/Events/CallbackScheduledEvent.cs @@ -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(); } diff --git a/Projects/UOContent/Engines/Events/CommonRecurrencePatterns.cs b/Projects/UOContent/Engines/Events/CommonRecurrencePatterns.cs index 5b13bb07e..fb7a11cf4 100644 --- a/Projects/UOContent/Engines/Events/CommonRecurrencePatterns.cs +++ b/Projects/UOContent/Engines/Events/CommonRecurrencePatterns.cs @@ -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; diff --git a/Projects/UOContent/Engines/Events/EventScheduler.cs b/Projects/UOContent/Engines/Events/EventScheduler.cs index a5e04f55f..cf681d33e 100644 --- a/Projects/UOContent/Engines/Events/EventScheduler.cs +++ b/Projects/UOContent/Engines/Events/EventScheduler.cs @@ -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. /// DateTime of the next occurence in UTC or DateTime.MaxValue /// - 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 _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); }