Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions IntervalAction.Test/IntervalActionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
{
Interlocked.Increment(ref executions);
// Simulate a long running task.
Thread.Sleep(500);

Check warning on line 49 in IntervalAction.Test/IntervalActionTests.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Do not use 'Thread.Sleep()' in a test.

Check warning on line 49 in IntervalAction.Test/IntervalActionTests.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Do not use 'Thread.Sleep()' in a test.

Check warning on line 49 in IntervalAction.Test/IntervalActionTests.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Do not use 'Thread.Sleep()' in a test.

Check warning on line 49 in IntervalAction.Test/IntervalActionTests.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Do not use 'Thread.Sleep()' in a test.
},
IntervalType = IntervalType.FromLastStart
};
Expand Down Expand Up @@ -485,4 +485,40 @@

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

Check warning on line 509 in IntervalAction.Test/IntervalActionTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_IntervalAction&issues=AaDqLjYqHjLGIDITxL8o&open=AaDqLjYqHjLGIDITxL8o&pullRequest=69
}

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

Check warning on line 520 in IntervalAction.Test/IntervalActionTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_IntervalAction&issues=AaDqLjYqHjLGIDITxL8p&open=AaDqLjYqHjLGIDITxL8p&pullRequest=69

Assert.IsTrue(intervalAction.TryRun(), "Expected the action to run once its interval had passed, whatever the wall clock says.");
}
}
54 changes: 44 additions & 10 deletions IntervalAction/IntervalAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace ktsu.IntervalAction;

using System.Diagnostics;
using System.Runtime.ExceptionServices;

/// <summary>
Expand All @@ -14,10 +15,21 @@ namespace ktsu.IntervalAction;
public class IntervalAction
{
/// <summary>
/// Gets or sets the last run time of the action.
/// Gets or sets the last run time of the action, as wall-clock time.
/// </summary>
/// <remarks>
/// For reporting only. Scheduling measures from <see cref="LastRunTimestamp"/>, 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.
/// </remarks>
internal DateTimeOffset LastRunTime { get; set; } = DateTimeOffset.MinValue;

/// <summary>
/// Gets or sets the <see cref="Stopwatch"/> timestamp of the last run, or <see langword="null"/>
/// if the action has not run yet.
/// </summary>
internal long? LastRunTimestamp { get; set; }

/// <summary>
/// Gets the polling interval for checking the action's status and attempting to start the action.
/// </summary>
Expand Down Expand Up @@ -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();
}
});

Expand All @@ -293,6 +299,34 @@ internal bool TryRun()
}
}

/// <summary>
/// Reports whether <see cref="ActionInterval"/> has passed since the last run, measured on a
/// monotonic clock. Callers hold <see cref="Lock"/>.
/// </summary>
private bool HasIntervalElapsed() =>
LastRunTimestamp is not long lastRun || GetElapsedTime(lastRun) > ActionInterval;

/// <summary>
/// Records that the action has just run.
/// </summary>
private void RecordRun()
{
lock (Lock)
{
LastRunTime = DateTimeOffset.Now;
LastRunTimestamp = Stopwatch.GetTimestamp();
}
}

/// <summary>
/// Returns the time elapsed since a <see cref="Stopwatch"/> timestamp.
/// </summary>
/// <param name="startingTimestamp">A value from <see cref="Stopwatch.GetTimestamp"/>.</param>
/// <returns>The time elapsed since <paramref name="startingTimestamp"/>.</returns>
/// <remarks>Stopwatch.GetElapsedTime does this, but only from .NET 7.</remarks>
private static TimeSpan GetElapsedTime(long startingTimestamp) =>
TimeSpan.FromTicks((long)((Stopwatch.GetTimestamp() - startingTimestamp) * ((double)TimeSpan.TicksPerSecond / Stopwatch.Frequency)));

/// <summary>
/// Rethrows any exceptions that occurred during the polling task.
/// </summary>
Expand Down
Loading