Skip to content

Commit

Permalink
Merge pull request #77 from clickviewapp/scheduled-worker
Browse files Browse the repository at this point in the history
Scheduler worker
  • Loading branch information
MrSmoke authored Apr 10, 2024
2 parents 400cab3 + 56a3d14 commit c04dbd4
Show file tree
Hide file tree
Showing 7 changed files with 256 additions and 4 deletions.
15 changes: 11 additions & 4 deletions ClickView.Extensions.sln
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.28803.352
# Visual Studio Version 17
VisualStudioVersion = 17.9.34701.34
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solution Items", "{1BF72BE4-3F51-462F-807B-DC9C87C5109C}"
ProjectSection(SolutionItems) = preProject
README.md = README.md
Directory.Build.props = Directory.Build.props
README.md = README.md
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Hosting", "Hosting", "{8BA73C9C-4630-4BC0-BE76-F867B00B3BD7}"
Expand Down Expand Up @@ -66,7 +66,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AspNetCore", "AspNetCore",
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ClickView.Extensions.RestClient.Authenticators.OAuth.AspNetCore", "src\RestClient\Authenticators\OAuth.AspNetCore\src\ClickView.Extensions.RestClient.Authenticators.OAuth.AspNetCore.csproj", "{10FB57EF-0737-4EAC-A6B9-372B15A6B85C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClickView.Extensions.RestClient.Tests", "src\RestClient\RestClient\test\ClickView.Extensions.RestClient.Tests.csproj", "{051B6555-18CA-45A6-984A-BC0818432A20}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ClickView.Extensions.RestClient.Tests", "src\RestClient\RestClient\test\ClickView.Extensions.RestClient.Tests.csproj", "{051B6555-18CA-45A6-984A-BC0818432A20}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClickView.Extensions.Hosting.Tests", "src\Hosting\Hosting\test\ClickView.Extensions.Hosting.Tests\ClickView.Extensions.Hosting.Tests.csproj", "{BA285C45-9D1B-4CE7-BF97-972A4D0D8377}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Expand Down Expand Up @@ -126,6 +128,10 @@ Global
{051B6555-18CA-45A6-984A-BC0818432A20}.Debug|Any CPU.Build.0 = Debug|Any CPU
{051B6555-18CA-45A6-984A-BC0818432A20}.Release|Any CPU.ActiveCfg = Release|Any CPU
{051B6555-18CA-45A6-984A-BC0818432A20}.Release|Any CPU.Build.0 = Release|Any CPU
{BA285C45-9D1B-4CE7-BF97-972A4D0D8377}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BA285C45-9D1B-4CE7-BF97-972A4D0D8377}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BA285C45-9D1B-4CE7-BF97-972A4D0D8377}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BA285C45-9D1B-4CE7-BF97-972A4D0D8377}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -148,6 +154,7 @@ Global
{39706C76-5265-4D07-8D6A-4294814E4482} = {1F86755A-71B2-4D6B-9F65-819B371F85C6}
{10FB57EF-0737-4EAC-A6B9-372B15A6B85C} = {39706C76-5265-4D07-8D6A-4294814E4482}
{051B6555-18CA-45A6-984A-BC0818432A20} = {BD0881FC-4083-49F5-B108-2205C691A9E4}
{BA285C45-9D1B-4CE7-BF97-972A4D0D8377} = {8BA73C9C-4630-4BC0-BE76-F867B00B3BD7}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3A15E820-F957-4F4D-AE2F-6EF055C27DB8}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Cronos" Version="0.8.4" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
</ItemGroup>
Expand Down
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}")
{
}
}
79 changes: 79 additions & 0 deletions src/Hosting/Hosting/src/Workers/CronWorker.cs
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);
}
52 changes: 52 additions & 0 deletions src/Hosting/Hosting/src/Workers/CronWorkerOption.cs
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");
}
}

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

0 comments on commit c04dbd4

Please sign in to comment.