diff --git a/CLAUDE.md b/CLAUDE.md index 3ba920d..8549c11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ Import-Module ./scripts/PSBuild.psm1 - Actions execute in separate tasks to prevent blocking the polling loop - Exception handling: Exceptions from actions are captured and can be rethrown via `RethrowExceptions()` - The library prevents overlapping executions - if an action is still running when the next interval arrives, it waits until the current action completes -- `LastRunTime` tracks when the action last executed (based on `IntervalType`) +- `LastRunTimestamp` (a `Stopwatch` timestamp) tracks when the action last executed (based on `IntervalType`), and is what scheduling measures from. `LastRunTime` records the same moment as wall-clock time for reporting only: the wall clock can be set back, which would hold the action off for as long as it moved - Polling can be stopped/restarted via `Stop()` and `Restart()`/`RestartAsync()` ## Testing diff --git a/IntervalAction.Test/IntervalActionTests.cs b/IntervalAction.Test/IntervalActionTests.cs index fc95cb8..bb361d5 100644 --- a/IntervalAction.Test/IntervalActionTests.cs +++ b/IntervalAction.Test/IntervalActionTests.cs @@ -485,4 +485,40 @@ public async Task StopIsIdempotent() intervalAction.RethrowExceptions(); } + + [TestMethod] + public async Task SettingTheWallClockBackDoesNotDelayTheNextRun() + { + int counter = 0; + IntervalActionOptions options = new() + { + // Long enough that the loop runs the action once and then leaves TryRun to the test. + PollingInterval = TimeSpan.FromHours(1), + ActionInterval = TimeSpan.FromMilliseconds(50), + Action = () => Interlocked.Increment(ref counter), + IntervalType = IntervalType.FromLastCompletion + }; + + IntervalAction intervalAction = IntervalAction.Start(options); + + // Wait for the first run to finish, so it has recorded when it ran. The loop then sits in its + // hour-long delay, and stopping it now keeps it from ever calling TryRun again. + DateTimeOffset deadline = DateTimeOffset.Now.AddSeconds(10); + while (intervalAction.ActionTask is not { IsCompleted: true } && DateTimeOffset.Now < deadline) + { + await Task.Delay(10).ConfigureAwait(false); + } + + intervalAction.Stop(); + Assert.AreEqual(1, counter, "Expected the first run to have happened."); + + // This is where a wall clock that has just been set back an hour puts the last run: an hour + // ahead of now. Only wall-clock time moves when the clock is set, so this is the whole of what + // a clock change does to the instance. + intervalAction.LastRunTime = DateTimeOffset.Now.AddHours(1); + + await Task.Delay(options.ActionInterval * 3).ConfigureAwait(false); + + Assert.IsTrue(intervalAction.TryRun(), "Expected the action to run once its interval had passed, whatever the wall clock says."); + } } diff --git a/IntervalAction/IntervalAction.cs b/IntervalAction/IntervalAction.cs index 8f014b6..182b9bf 100644 --- a/IntervalAction/IntervalAction.cs +++ b/IntervalAction/IntervalAction.cs @@ -2,6 +2,7 @@ namespace ktsu.IntervalAction; +using System.Diagnostics; using System.Runtime.ExceptionServices; /// @@ -14,10 +15,21 @@ namespace ktsu.IntervalAction; public class IntervalAction { /// - /// Gets or sets the last run time of the action. + /// Gets or sets the last run time of the action, as wall-clock time. /// + /// + /// For reporting only. Scheduling measures from , because the wall + /// clock can be set back, and a last run that appears to lie in the future would hold the action + /// off for as long as the clock moved. + /// internal DateTimeOffset LastRunTime { get; set; } = DateTimeOffset.MinValue; + /// + /// Gets or sets the timestamp of the last run, or + /// if the action has not run yet. + /// + internal long? LastRunTimestamp { get; set; } + /// /// Gets the polling interval for checking the action's status and attempting to start the action. /// @@ -263,26 +275,20 @@ internal bool TryRun() ActionTask = null; } - if (ActionInterval >= TimeSpan.Zero && ActionTask is null && DateTimeOffset.Now - LastRunTime > ActionInterval) + if (ActionInterval >= TimeSpan.Zero && ActionTask is null && HasIntervalElapsed()) { ActionTask = Task.Run(() => { if (IntervalType == IntervalType.FromLastStart) { - lock (Lock) - { - LastRunTime = DateTimeOffset.Now; - } + RecordRun(); } Action(); if (IntervalType == IntervalType.FromLastCompletion) { - lock (Lock) - { - LastRunTime = DateTimeOffset.Now; - } + RecordRun(); } }); @@ -293,6 +299,34 @@ internal bool TryRun() } } + /// + /// Reports whether has passed since the last run, measured on a + /// monotonic clock. Callers hold . + /// + private bool HasIntervalElapsed() => + LastRunTimestamp is not long lastRun || GetElapsedTime(lastRun) > ActionInterval; + + /// + /// Records that the action has just run. + /// + private void RecordRun() + { + lock (Lock) + { + LastRunTime = DateTimeOffset.Now; + LastRunTimestamp = Stopwatch.GetTimestamp(); + } + } + + /// + /// Returns the time elapsed since a timestamp. + /// + /// A value from . + /// The time elapsed since . + /// Stopwatch.GetElapsedTime does this, but only from .NET 7. + private static TimeSpan GetElapsedTime(long startingTimestamp) => + TimeSpan.FromTicks((long)((Stopwatch.GetTimestamp() - startingTimestamp) * ((double)TimeSpan.TicksPerSecond / Stopwatch.Frequency))); + /// /// Rethrows any exceptions that occurred during the polling task. ///