-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #77 from clickviewapp/scheduled-worker
Scheduler worker
- Loading branch information
Showing
7 changed files
with
256 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
12 changes: 12 additions & 0 deletions
12
src/Hosting/Hosting/src/Exceptions/InvalidCronWorkerOptionException.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
namespace ClickView.Extensions.Hosting.Exceptions; | ||
using System; | ||
using System.Collections.Generic; | ||
using System.Text; | ||
|
||
public class InvalidCronWorkerOptionException : Exception | ||
{ | ||
public InvalidCronWorkerOptionException(string optionPropertyName, string message) | ||
: base($"Invalid option value for {optionPropertyName}. {message}") | ||
{ | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
namespace ClickView.Extensions.Hosting.Workers; | ||
|
||
using System; | ||
using System.Threading; | ||
using System.Threading.Tasks; | ||
using Cronos; | ||
using Microsoft.Extensions.Logging; | ||
|
||
public abstract class CronWorker(CronWorkerOption option, ILogger logger) : Worker(logger) | ||
{ | ||
private readonly ILogger _logger = logger; | ||
private readonly Random _delayGenerator = new(); | ||
private readonly CronWorkerOption? _option = option; | ||
|
||
protected override async Task ExecuteAsync(CancellationToken cancellationToken) | ||
{ | ||
while (!cancellationToken.IsCancellationRequested) | ||
{ | ||
var dueTime = GetNextScheduleDelay(); | ||
if (!dueTime.HasValue) | ||
{ | ||
_logger.LogWarning("Failed to get next schedule"); | ||
|
||
// Wait for a while before retrying | ||
await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); | ||
continue; | ||
} | ||
|
||
try | ||
{ | ||
await Task.Delay(dueTime.Value, cancellationToken); | ||
} | ||
catch (OperationCanceledException) | ||
{ | ||
return; | ||
} | ||
|
||
try | ||
{ | ||
await RunAsync(cancellationToken); | ||
} | ||
catch (Exception ex) | ||
{ | ||
_logger.LogError(ex, "Unhandled exception caught in CronWorker ({WorkerName})", Name); | ||
} | ||
} | ||
} | ||
|
||
private TimeSpan? GetNextScheduleDelay() | ||
{ | ||
// Use try/catch here so that we don't crash the app if anything goes wrong with getting the next schedule time | ||
try | ||
{ | ||
var now = DateTime.UtcNow; | ||
|
||
var cronValue = CronExpression.Parse(_option?.Schedule, CronFormat.IncludeSeconds); | ||
var next = cronValue.GetNextOccurrence(now); | ||
if (!next.HasValue) | ||
return null; | ||
|
||
var delay = next.Value - now; | ||
|
||
if (_option is null || !_option.AllowJitter) | ||
return delay; | ||
|
||
var extraDelay = TimeSpan.FromSeconds(_delayGenerator.Next((int)_option.MinJitter.TotalSeconds, (int)_option.MaxJitter.TotalSeconds)); | ||
|
||
return delay.Add(extraDelay); | ||
} | ||
catch (Exception ex) | ||
{ | ||
_logger.LogError(ex, "Failed to get the next schedule time"); | ||
} | ||
|
||
return null; | ||
} | ||
|
||
protected abstract Task RunAsync(CancellationToken cancellationToken); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
namespace ClickView.Extensions.Hosting.Workers; | ||
|
||
using System; | ||
using Cronos; | ||
using Exceptions; | ||
|
||
public record CronWorkerOption | ||
{ | ||
public bool AllowJitter { get; } | ||
/// <summary> | ||
/// Minimum extra time in second delayed before the scheduled task can start | ||
/// </summary> | ||
public TimeSpan MinJitter { get; } = TimeSpan.Zero; | ||
/// <summary> | ||
/// Maximum extra time in second delayed before the scheduled task can start | ||
/// </summary> | ||
public TimeSpan MaxJitter { get; } = TimeSpan.FromSeconds(120); | ||
|
||
/// <summary> | ||
/// Cron expression that includes second | ||
/// Reference: https://www.nuget.org/packages/Cronos/ | ||
/// </summary> | ||
public string Schedule { get; } | ||
|
||
public CronWorkerOption(string schedule) | ||
{ | ||
Schedule = schedule; | ||
} | ||
|
||
public CronWorkerOption(string schedule, bool allowJitter, TimeSpan minJitter, TimeSpan maxJitter) | ||
{ | ||
Schedule = schedule; | ||
AllowJitter = allowJitter; | ||
MinJitter = minJitter; | ||
MaxJitter = maxJitter; | ||
|
||
Validate(); | ||
} | ||
|
||
private void Validate() | ||
{ | ||
if (!CronExpression.TryParse(Schedule, CronFormat.IncludeSeconds, out var _)) | ||
throw new InvalidCronWorkerOptionException(nameof(Schedule), $"{nameof(Schedule)} is not in a correct cron format"); | ||
|
||
if (MinJitter >= MaxJitter) | ||
throw new InvalidCronWorkerOptionException(nameof(MinJitter), $"{nameof(MinJitter)} value must be less than {nameof(MaxJitter)} value"); | ||
|
||
if (MaxJitter > TimeSpan.FromSeconds(120)) | ||
throw new InvalidCronWorkerOptionException(nameof(MaxJitter), $"{nameof(MaxJitter)} value must not exceed 120 seconds"); | ||
} | ||
} | ||
|
28 changes: 28 additions & 0 deletions
28
...Hosting/test/ClickView.Extensions.Hosting.Tests/ClickView.Extensions.Hosting.Tests.csproj
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
<Project Sdk="Microsoft.NET.Sdk"> | ||
|
||
<PropertyGroup> | ||
<TargetFramework>net8.0</TargetFramework> | ||
<ImplicitUsings>enable</ImplicitUsings> | ||
<Nullable>enable</Nullable> | ||
|
||
<IsPackable>false</IsPackable> | ||
<IsTestProject>true</IsTestProject> | ||
</PropertyGroup> | ||
|
||
<ItemGroup> | ||
<PackageReference Include="coverlet.collector" Version="6.0.0" /> | ||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /> | ||
<PackageReference Include="Moq" Version="4.18.4" /> | ||
<PackageReference Include="xunit" Version="2.5.3" /> | ||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" /> | ||
</ItemGroup> | ||
|
||
<ItemGroup> | ||
<ProjectReference Include="..\..\src\ClickView.Extensions.Hosting.csproj" /> | ||
</ItemGroup> | ||
|
||
<ItemGroup> | ||
<Using Include="Xunit" /> | ||
</ItemGroup> | ||
|
||
</Project> |
73 changes: 73 additions & 0 deletions
73
src/Hosting/Hosting/test/ClickView.Extensions.Hosting.Tests/CronWorkerTests.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
namespace ClickView.Extensions.Hosting.Tests; | ||
|
||
using Exceptions; | ||
using Microsoft.Extensions.Logging; | ||
using Moq; | ||
using Workers; | ||
|
||
public class CronWorkerTests | ||
{ | ||
[Fact] | ||
public async Task RunAsync_RunEveryTwoSeconds() | ||
{ | ||
const string everyTwoSecondCron = "*/2 * * * * *"; | ||
var option = new CronWorkerOption(everyTwoSecondCron); | ||
|
||
var mockLogger = new Mock<ILogger>(); | ||
var scheduler = new TestScheduler(option, mockLogger.Object); | ||
|
||
await scheduler.StartAsync(CancellationToken.None); | ||
|
||
await Task.Delay(TimeSpan.FromSeconds(5)); | ||
|
||
await scheduler.StopAsync(CancellationToken.None); | ||
|
||
Assert.True(scheduler.Counter >= 2); | ||
} | ||
|
||
[Fact] | ||
public void CronWorkerOption_MinGreaterThanMax_ThrowException() | ||
{ | ||
const string everyTwoSecondCron = "*/2 * * * * *"; | ||
|
||
Assert.Throws<InvalidCronWorkerOptionException>(() => | ||
{ new CronWorkerOption(everyTwoSecondCron, true, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(1)); }); | ||
} | ||
|
||
[Fact] | ||
public async Task RunAsync_RunEveryTwoSecondsWithJitter_Executes() | ||
{ | ||
const string everyTwoSecondCron = "*/2 * * * * *"; | ||
var option = new CronWorkerOption(everyTwoSecondCron, true, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(3)); | ||
|
||
var mockLogger = new Mock<ILogger>(); | ||
var scheduler = new TestScheduler(option, mockLogger.Object); | ||
|
||
await scheduler.StartAsync(CancellationToken.None); | ||
|
||
var currentTime = DateTime.UtcNow; | ||
|
||
// Give it a bit more time to ensure the task is executed | ||
await Task.Delay(TimeSpan.FromSeconds(4)); | ||
|
||
await scheduler.StopAsync(CancellationToken.None); | ||
|
||
Assert.NotNull(scheduler.FirstExecutionTime); | ||
Assert.True(scheduler.FirstExecutionTime.Value - currentTime > TimeSpan.FromSeconds(2)); | ||
} | ||
|
||
public class TestScheduler(CronWorkerOption option, ILogger logger) : CronWorker(option, logger) | ||
{ | ||
public int Counter { get; set; } | ||
public DateTime? FirstExecutionTime { get; set; } | ||
|
||
protected override Task RunAsync(CancellationToken cancellationToken) | ||
{ | ||
FirstExecutionTime ??= DateTime.UtcNow; | ||
|
||
Counter++; | ||
|
||
return Task.CompletedTask; | ||
} | ||
} | ||
} |