From 280acb1649cb25af21f78a3ff30d27b2dabe2f04 Mon Sep 17 00:00:00 2001 From: "Frank R. Haugen" Date: Sat, 3 Feb 2024 17:26:47 +0100 Subject: [PATCH] Remove outdated cron job implementation This change removes the existing cron job implementation, which included removing several classes related to Cron job processing and scheduling. This significant update was necessary to pave the way for a new, improved implementation that promises better efficiency and maintainability. Please note that any classes utilizing this previous cron job scheme will need to be updated. --- Frank.CronJobs.Cron/CronHelper.cs | 24 +++ Frank.CronJobs.Cron/README.md | 90 ++++++++++++ .../Frank.CronJobs.SampleApp.csproj | 16 ++ Frank.CronJobs.SampleApp/Program.cs | 40 +++++ Frank.CronJobs.Tests/CronJobSchedulerTests.cs | 32 ++++ .../ServiceCollectionExtensionsTests.cs | 72 --------- .../Frank.CronJobs.Tests.csproj | 1 + .../ScheduleMaintainerChangeScheduleTests.cs | 42 ++++++ .../ScheduleMaintainerRestartJobTest.cs | 46 ++++++ .../ScheduleMaintainerStopJobTest.cs | 42 ++++++ .../ServiceCollectionExtensionsTests.cs | 46 ++++++ Frank.CronJobs.sln | 6 + Frank.CronJobs/CronJobOptions.cs | 46 ------ Frank.CronJobs/Frank.CronJobs.csproj | 2 - Frank.CronJobs/ICronJob.cs | 6 +- Frank.CronJobs/ICronJobsBuilder.cs | 41 ------ Frank.CronJobs/IScheduleMaintainer.cs | 49 ++++++ Frank.CronJobs/Internals/CronJobDescriptor.cs | 11 ++ .../Internals/CronJobOptionsCollection.cs | 37 ----- Frank.CronJobs/Internals/CronJobRunner.cs | 139 ------------------ .../Internals/CronJobRunnerOptions.cs | 34 ----- Frank.CronJobs/Internals/CronJobScheduler.cs | 83 +++++++++++ Frank.CronJobs/Internals/CronJobsBuilder.cs | 35 ----- .../Internals/ICronJobDescriptor.cs | 9 ++ Frank.CronJobs/Internals/JobInterval.cs | 86 +++++------ .../Internals/ScheduleMaintainer.cs | 60 ++++++++ .../InvalidCronExpressionException.cs | 6 + Frank.CronJobs/ServiceCollectionExtensions.cs | 91 ++++++------ Frank.CronJobs/TimeZoneOptions.cs | 50 ------- README.md | 78 +++++++++- 30 files changed, 766 insertions(+), 554 deletions(-) create mode 100644 Frank.CronJobs.Cron/README.md create mode 100644 Frank.CronJobs.SampleApp/Frank.CronJobs.SampleApp.csproj create mode 100644 Frank.CronJobs.SampleApp/Program.cs create mode 100644 Frank.CronJobs.Tests/CronJobSchedulerTests.cs delete mode 100644 Frank.CronJobs.Tests/DependencyInjection/ServiceCollectionExtensionsTests.cs create mode 100644 Frank.CronJobs.Tests/ScheduleMaintainer/ScheduleMaintainerChangeScheduleTests.cs create mode 100644 Frank.CronJobs.Tests/ScheduleMaintainer/ScheduleMaintainerRestartJobTest.cs create mode 100644 Frank.CronJobs.Tests/ScheduleMaintainer/ScheduleMaintainerStopJobTest.cs create mode 100644 Frank.CronJobs.Tests/ServiceCollectionExtensionsTests.cs delete mode 100644 Frank.CronJobs/CronJobOptions.cs delete mode 100644 Frank.CronJobs/ICronJobsBuilder.cs create mode 100644 Frank.CronJobs/IScheduleMaintainer.cs create mode 100644 Frank.CronJobs/Internals/CronJobDescriptor.cs delete mode 100644 Frank.CronJobs/Internals/CronJobOptionsCollection.cs delete mode 100644 Frank.CronJobs/Internals/CronJobRunner.cs delete mode 100644 Frank.CronJobs/Internals/CronJobRunnerOptions.cs create mode 100644 Frank.CronJobs/Internals/CronJobScheduler.cs delete mode 100644 Frank.CronJobs/Internals/CronJobsBuilder.cs create mode 100644 Frank.CronJobs/Internals/ICronJobDescriptor.cs create mode 100644 Frank.CronJobs/Internals/ScheduleMaintainer.cs create mode 100644 Frank.CronJobs/InvalidCronExpressionException.cs delete mode 100644 Frank.CronJobs/TimeZoneOptions.cs diff --git a/Frank.CronJobs.Cron/CronHelper.cs b/Frank.CronJobs.Cron/CronHelper.cs index 659f5fd..fd80ab6 100644 --- a/Frank.CronJobs.Cron/CronHelper.cs +++ b/Frank.CronJobs.Cron/CronHelper.cs @@ -5,6 +5,30 @@ /// public static class CronHelper { + /// + /// Parses a cron expression and returns a CronExpression object. + /// + /// The cron expression to parse. + /// A CronExpression object representing the parsed cron expression. + public static CronExpression Parse(string expression) => new(expression); + + /// + /// Tries to parse the provided cron expression and creates a CronExpression object if the expression is valid. + /// + /// The cron expression to parse. + /// When this method returns, contains the CronExpression object created from the parsed expression if the expression is valid; otherwise, contains null. + /// Returns true if the cron expression was successfully parsed and created into a CronExpression object; otherwise, returns false. + public static bool TryParse(string expression, out CronExpression? cronExpression) + { + if (IsValid(expression)) + { + cronExpression = new CronExpression(expression); + return true; + } + cronExpression = null; + return false; + } + /// /// Calculates the next occurrence of a cron expression based on the current UTC time. /// diff --git a/Frank.CronJobs.Cron/README.md b/Frank.CronJobs.Cron/README.md new file mode 100644 index 0000000..1c4d41f --- /dev/null +++ b/Frank.CronJobs.Cron/README.md @@ -0,0 +1,90 @@ +# Frank.CronJobs.Cron + +Frank.CronJobs.Cron is a .NET library that provides cron expression parsing and scheduling capabilities. Its meant as an +internal dependency for Frank.CronJobs, but works just fine as a CronParser + +## Features + +- Parse cron expressions and validate syntax +- Calculate next occurrence datetime based on cron expression +- Determine if cron expression is due relative to specified datetime +- Helper methods for working with cron expressions +- Constants for common Cron Expressions + +## Usage +Parse and validate cron expression + +```csharp +string expression = "0 15 10 * * ?"; + +CronExpression cron = new CronExpression(expression); + +bool isValid = cron.IsValid; +``` + +Calculate next occurrence + +```csharp +string expression = "0 15 10 * * ?"; + +DateTime next = CronExpression.GetNextOccurrence(expression); +``` + +Check if cron is due + +```csharp +string expression = "0 15 10 * * *"; +DateTime dateTime = new DateTime(2023, 2, 15, 11, 0, 0); + +bool isDue = CronExpression.IsDue(expression, dateTime); // true +``` + +Use helper methods + +```csharp +// Get next occurrence from current time +DateTime next = CronHelper.GetNextOccurrence(expression); + +// Get time until next occurrence +TimeSpan timeToNext = CronHelper.GetTimeUntilNextOccurrence(expression); + +// Check if due +bool isDue = CronHelper.IsDue(expression); +``` + +Use common cron expressions + +```csharp +string everySecond = PredefinedCronExpressions.EverySecond; +string everyMinute = PredefinedCronExpressions.EveryMinute; +string everyHour = PredefinedCronExpressions.EveryHour; +string everyDay = PredefinedCronExpressions.EveryDay; +string everyWeek = PredefinedCronExpressions.EveryWeek; +string everyMonth = PredefinedCronExpressions.EveryMonth; +string everyYear = PredefinedCronExpressions.EveryYear; + +string everyYearOnChristmasEve = PredefinedCronExpressions.EveryYearOn.ChristmasEve; +``` + +## Installation +Install the NuGet package directly from the package manager console: + +```powershell +PM> Install-Package Frank.CronJobs.Cron +``` + +## License + +Frank.CronJobs.Cron is licensed under the [MIT license](../LICENSE). + +## Contributing + +Contributions, except for actual bug fixes, are not welcome at this time. This is an internal dependency for Frank.CronJobs, +and though it is a standalone library, it is not meant to be developed as such. If you have a bug fix, please submit a pull +with a test that demonstrates the bug and the fix. + +## Credits + +This library is based on [CronQuery](https://github.com/marxjmoura/cronquery), which I am a contributor to. This is built on +that code to change it in a few ways to better suit my needs for Frank.CronJobs, and make it a standalone library so the +lightweight cron parsing can be used in other projects as well with no dependencies. \ No newline at end of file diff --git a/Frank.CronJobs.SampleApp/Frank.CronJobs.SampleApp.csproj b/Frank.CronJobs.SampleApp/Frank.CronJobs.SampleApp.csproj new file mode 100644 index 0000000..a9217e0 --- /dev/null +++ b/Frank.CronJobs.SampleApp/Frank.CronJobs.SampleApp.csproj @@ -0,0 +1,16 @@ + + + + Exe + false + + + + + + + + + + + diff --git a/Frank.CronJobs.SampleApp/Program.cs b/Frank.CronJobs.SampleApp/Program.cs new file mode 100644 index 0000000..b608108 --- /dev/null +++ b/Frank.CronJobs.SampleApp/Program.cs @@ -0,0 +1,40 @@ +// See https://aka.ms/new-console-template for more information + +using System.Text.Json; +using Frank.CronJobs; +using Frank.CronJobs.Cron; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +Console.WriteLine("Hello, World!"); + +var builder = new HostBuilder(); +builder.ConfigureLogging(logging => +{ + logging.AddJsonConsole(options => + { + options.JsonWriterOptions = new JsonWriterOptions + { + Indented = true, + }; + options.IncludeScopes = true; + options.TimestampFormat = "yyyy-MM-dd HH:mm:ss.fff"; + options.UseUtcTimestamp = true; + }); +}); +builder.ConfigureServices((context, services) => +{ + services.AddCronJob(PredefinedCronExpressions.EverySecond); +}); + +await builder.RunConsoleAsync(); + +public class MyService(ILogger logger) : ICronJob +{ + /// + public async Task RunAsync(CancellationToken cancellationToken) + { + logger.LogInformation("Running"); + await Task.Delay(100, cancellationToken); + } +} \ No newline at end of file diff --git a/Frank.CronJobs.Tests/CronJobSchedulerTests.cs b/Frank.CronJobs.Tests/CronJobSchedulerTests.cs new file mode 100644 index 0000000..fbf6e74 --- /dev/null +++ b/Frank.CronJobs.Tests/CronJobSchedulerTests.cs @@ -0,0 +1,32 @@ +using Frank.CronJobs.Cron; +using Frank.Testing.TestBases; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Frank.CronJobs.Tests; + +public class CronJobSchedulerTests(ITestOutputHelper outputHelper) : HostApplicationTestBase(outputHelper) +{ + /// + protected override Task SetupAsync(HostApplicationBuilder builder) + { + builder.Services.AddCronJob(PredefinedCronExpressions.EverySecond); + return Task.CompletedTask; + } + + [Fact] + public async Task Test() + { + await Task.Delay(5000); + } + + private class MyService(ILogger logger) : ICronJob + { + public async Task RunAsync(CancellationToken cancellationToken) + { + logger.LogInformation("Running"); + await Task.Delay(100, cancellationToken); + } + } +} \ No newline at end of file diff --git a/Frank.CronJobs.Tests/DependencyInjection/ServiceCollectionExtensionsTests.cs b/Frank.CronJobs.Tests/DependencyInjection/ServiceCollectionExtensionsTests.cs deleted file mode 100644 index 3b3a426..0000000 --- a/Frank.CronJobs.Tests/DependencyInjection/ServiceCollectionExtensionsTests.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Frank.Testing.Logging; -using Frank.Testing.TestBases; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Xunit.Abstractions; - -namespace Frank.CronJobs.Tests.DependencyInjection; - -public class ServiceCollectionExtensionsTests(ITestOutputHelper outputHelper) : HostApplicationTestBase(outputHelper) -{ - /// - protected override Task SetupAsync(HostApplicationBuilder builder) - { - builder.Configuration.AddInMemoryCollection(new Dictionary - { - {"CronJobRunnerOptions:Running", "true"}, - }!); - builder.Services.AddCronJobs(builder.Configuration, cronJobBuilder => - { - cronJobBuilder.AddCronJob(options => - { - options.Cron = "* * * * * *"; - options.Running = true; - }); - cronJobBuilder.AddCronJob(options => - { - options.Cron = "* * * * * *"; - options.Running = true; - }); - }); - return Task.CompletedTask; - } - - [Fact] - public async Task AddCronJob_WithCronExpression_ShouldRunAsync() - { - await Task.Delay(5000); - } - - private class MyService : ICronJob - { - private readonly ILogger _logger; - - public MyService(ILogger logger) - { - _logger = logger; - } - - public async Task RunAsync() - { - _logger.LogInformation("Running"); - await Task.Delay(100); - } - } - - private class MyOtherService : ICronJob - { - private readonly ILogger _logger; - - public MyOtherService(ILogger logger) - { - _logger = logger; - } - - public async Task RunAsync() - { - _logger.LogInformation("Running other"); - await Task.Delay(100); - } - } -} \ No newline at end of file diff --git a/Frank.CronJobs.Tests/Frank.CronJobs.Tests.csproj b/Frank.CronJobs.Tests/Frank.CronJobs.Tests.csproj index f99f940..bd4012d 100644 --- a/Frank.CronJobs.Tests/Frank.CronJobs.Tests.csproj +++ b/Frank.CronJobs.Tests/Frank.CronJobs.Tests.csproj @@ -6,6 +6,7 @@ + diff --git a/Frank.CronJobs.Tests/ScheduleMaintainer/ScheduleMaintainerChangeScheduleTests.cs b/Frank.CronJobs.Tests/ScheduleMaintainer/ScheduleMaintainerChangeScheduleTests.cs new file mode 100644 index 0000000..0b5f444 --- /dev/null +++ b/Frank.CronJobs.Tests/ScheduleMaintainer/ScheduleMaintainerChangeScheduleTests.cs @@ -0,0 +1,42 @@ +using FluentAssertions; +using Frank.CronJobs.Cron; +using Frank.Testing.TestBases; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Frank.CronJobs.Tests.ScheduleMaintainer; + +public class ScheduleMaintainerChangeScheduleTests(ITestOutputHelper outputHelper) : HostApplicationTestBase(outputHelper) +{ + private readonly List _runTimes = new(); + + /// + protected override Task SetupAsync(HostApplicationBuilder builder) + { + builder.Services.AddCronJob(PredefinedCronExpressions.EverySecond); + builder.Services.AddSingleton(_runTimes); + return Task.CompletedTask; + } + + [Fact] + public async Task Test() + { + await Task.Delay(1500); + _runTimes.Should().HaveCount(1); + await Task.Delay(6000); + _runTimes.Should().HaveCountGreaterThan(1); + } + + private class MyService(ILogger logger, List runTimes, IScheduleMaintainer maintainer) : ICronJob + { + public async Task RunAsync(CancellationToken cancellationToken) + { + logger.LogInformation("Running"); + runTimes.Add(DateTime.UtcNow); + await Task.Delay(250, cancellationToken); + maintainer.SetSchedule(PredefinedCronExpressions.EveryFiveSeconds); + } + } +} \ No newline at end of file diff --git a/Frank.CronJobs.Tests/ScheduleMaintainer/ScheduleMaintainerRestartJobTest.cs b/Frank.CronJobs.Tests/ScheduleMaintainer/ScheduleMaintainerRestartJobTest.cs new file mode 100644 index 0000000..d84f0cf --- /dev/null +++ b/Frank.CronJobs.Tests/ScheduleMaintainer/ScheduleMaintainerRestartJobTest.cs @@ -0,0 +1,46 @@ +using FluentAssertions; +using Frank.CronJobs.Cron; +using Frank.Testing.TestBases; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Frank.CronJobs.Tests.ScheduleMaintainer; + +public class ScheduleMaintainerRestartJobTest(ITestOutputHelper outputHelper) : HostApplicationTestBase(outputHelper) +{ + private readonly List _runVersions = new(); + + /// + protected override Task SetupAsync(HostApplicationBuilder builder) + { + builder.Services.AddCronJob(PredefinedCronExpressions.EverySecond); + builder.Services.AddSingleton(_runVersions); + return Task.CompletedTask; + } + + [Fact] + public async Task Test3() + { + await Task.Delay(1500); + _runVersions.Should().HaveCount(1); + await Task.Delay(6000); + _runVersions.Should().HaveCount(1); + var maintainer = Services.GetRequiredService(); + maintainer.Start(); + await Task.Delay(1000); + _runVersions.Should().HaveCountGreaterOrEqualTo(2); + } + + private class MyRestartingService(ILogger logger, List runVersions, IScheduleMaintainer maintainer) : ICronJob + { + public async Task RunAsync(CancellationToken cancellationToken) + { + logger.LogInformation("Running restart service"); + runVersions.Add(new Version(1, 0, 0, 0)); + await Task.Delay(250, cancellationToken); + maintainer.Stop(); + } + } +} \ No newline at end of file diff --git a/Frank.CronJobs.Tests/ScheduleMaintainer/ScheduleMaintainerStopJobTest.cs b/Frank.CronJobs.Tests/ScheduleMaintainer/ScheduleMaintainerStopJobTest.cs new file mode 100644 index 0000000..b33bda6 --- /dev/null +++ b/Frank.CronJobs.Tests/ScheduleMaintainer/ScheduleMaintainerStopJobTest.cs @@ -0,0 +1,42 @@ +using FluentAssertions; +using Frank.CronJobs.Cron; +using Frank.Testing.TestBases; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Frank.CronJobs.Tests.ScheduleMaintainer; + +public class ScheduleMaintainerStopJobTest(ITestOutputHelper outputHelper) : HostApplicationTestBase(outputHelper) +{ + private readonly List _runIds = new(); + + /// + protected override Task SetupAsync(HostApplicationBuilder builder) + { + builder.Services.AddCronJob(PredefinedCronExpressions.EverySecond); + builder.Services.AddSingleton(_runIds); + return Task.CompletedTask; + } + + [Fact] + public async Task Test2() + { + await Task.Delay(1500); + _runIds.Should().HaveCount(1); + await Task.Delay(6000); + _runIds.Should().HaveCount(1); + } + + private class MyStoppingService(ILogger logger, List runIds, IScheduleMaintainer maintainer) : ICronJob + { + public async Task RunAsync(CancellationToken cancellationToken) + { + logger.LogInformation("Running Stop service"); + runIds.Add(Guid.NewGuid()); + await Task.Delay(250, cancellationToken); + maintainer.Stop(); + } + } +} \ No newline at end of file diff --git a/Frank.CronJobs.Tests/ServiceCollectionExtensionsTests.cs b/Frank.CronJobs.Tests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..48c61b3 --- /dev/null +++ b/Frank.CronJobs.Tests/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,46 @@ +using FluentAssertions; +using Frank.Reflection; +using Microsoft.Extensions.DependencyInjection; + +namespace Frank.CronJobs.Tests; + +public class ServiceCollectionExtensionsTests +{ + [Fact] + public void AddCronJob_WithValidCronExpression_AddsCronJobToServiceCollection() + { + // Arrange + var serviceCollection = new ServiceCollection(); + var cronExpression = "0 0 * * * *"; + + // Act + serviceCollection.AddCronJob(cronExpression); + + // Assert + var serviceProvider = serviceCollection.BuildServiceProvider(); + var cronJob = serviceProvider.GetRequiredKeyedService(typeof(MockCronJob).GetFullDisplayName()); + Assert.IsType(cronJob); + } + + [Fact] + public void AddCronJob_WithInvalidCronExpression_ThrowsInvalidCronExpressionException() + { + // Arrange + var serviceCollection = new ServiceCollection(); + var cronExpression = "invalid"; + + // Act + Action act = () => serviceCollection.AddCronJob(cronExpression); + + // Assert + act.Should().Throw(); + } + + public class MockCronJob : ICronJob + { + public Task RunAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/Frank.CronJobs.sln b/Frank.CronJobs.sln index f7082e8..7bbbb16 100644 --- a/Frank.CronJobs.sln +++ b/Frank.CronJobs.sln @@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Frank.CronJobs.Tests", "Fra EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Frank.CronJobs.Cron", "Frank.CronJobs.Cron\Frank.CronJobs.Cron.csproj", "{B5F0B1C0-693F-4D3A-9426-EF362879437F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Frank.CronJobs.SampleApp", "Frank.CronJobs.SampleApp\Frank.CronJobs.SampleApp.csproj", "{705728AA-3E92-4444-903E-21D7D9C8703A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -40,5 +42,9 @@ Global {B5F0B1C0-693F-4D3A-9426-EF362879437F}.Debug|Any CPU.Build.0 = Debug|Any CPU {B5F0B1C0-693F-4D3A-9426-EF362879437F}.Release|Any CPU.ActiveCfg = Release|Any CPU {B5F0B1C0-693F-4D3A-9426-EF362879437F}.Release|Any CPU.Build.0 = Release|Any CPU + {705728AA-3E92-4444-903E-21D7D9C8703A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {705728AA-3E92-4444-903E-21D7D9C8703A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {705728AA-3E92-4444-903E-21D7D9C8703A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {705728AA-3E92-4444-903E-21D7D9C8703A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/Frank.CronJobs/CronJobOptions.cs b/Frank.CronJobs/CronJobOptions.cs deleted file mode 100644 index c48f8d7..0000000 --- a/Frank.CronJobs/CronJobOptions.cs +++ /dev/null @@ -1,46 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2018 Marx J. Moura - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -namespace Frank.CronJobs; - -/// -/// Represents the options for a cron job. -/// -public sealed class CronJobOptions -{ - /// - /// Gets or sets a value indicating whether the cron job is currently running. - /// - public bool Running { get; set; } - - /// - /// Represents a unique identifier for the cron job. - /// - public string Name { get; set; } = null!; - - /// - /// Represents a Cron expression. - /// - public string Cron { get; set; } = "* * * * * *"; -} diff --git a/Frank.CronJobs/Frank.CronJobs.csproj b/Frank.CronJobs/Frank.CronJobs.csproj index bd0cbe0..9d827c0 100644 --- a/Frank.CronJobs/Frank.CronJobs.csproj +++ b/Frank.CronJobs/Frank.CronJobs.csproj @@ -7,8 +7,6 @@ - - diff --git a/Frank.CronJobs/ICronJob.cs b/Frank.CronJobs/ICronJob.cs index 961605c..733987c 100644 --- a/Frank.CronJobs/ICronJob.cs +++ b/Frank.CronJobs/ICronJob.cs @@ -1,10 +1,6 @@ -/* - * Based on "CronQuery", which is licensed under the MIT license - */ - namespace Frank.CronJobs; public interface ICronJob { - Task RunAsync(); + Task RunAsync(CancellationToken cancellationToken); } diff --git a/Frank.CronJobs/ICronJobsBuilder.cs b/Frank.CronJobs/ICronJobsBuilder.cs deleted file mode 100644 index b8aee9a..0000000 --- a/Frank.CronJobs/ICronJobsBuilder.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Frank.CronJobs.Cron; - -namespace Frank.CronJobs; - -/// -/// Represents a builder for configuring cron jobs. -/// -public interface ICronJobsBuilder -{ - /// - /// Adds a cron job of type to the cron jobs builder. The cron job will be configured using the given action. - /// - /// The type of the cron job. - /// An action to configure the cron job options. - /// The cron jobs builder instance. - ICronJobsBuilder AddCronJob(Action configure) where T : class, ICronJob; - - /// - /// Adds a cron job of type to the cron jobs builder with the specified options. - /// - /// The type of the cron job to add. - /// The options for the cron job. - /// The updated instance of the cron jobs builder. - ICronJobsBuilder AddCronJob(CronJobOptions options) where T : class, ICronJob; - - /// - /// Adds a cron job of type T to the cron jobs builder with the specified cron expression. - /// - /// The type of the cron job to add. Must implement the ICronJob interface. - /// The cron expression to use for scheduling the cron job. - /// An instance of ICronJobsBuilder for method chaining. - ICronJobsBuilder AddCronJob(string cron) where T : class, ICronJob; - - /// - /// Adds a cron job to the cron jobs builder. - /// - /// The type of cron job. - /// The cron expression to schedule the job. - /// The same cron jobs builder instance. - ICronJobsBuilder AddCronJob(CronExpression cronExpression) where T : class, ICronJob; -} \ No newline at end of file diff --git a/Frank.CronJobs/IScheduleMaintainer.cs b/Frank.CronJobs/IScheduleMaintainer.cs new file mode 100644 index 0000000..0315c7f --- /dev/null +++ b/Frank.CronJobs/IScheduleMaintainer.cs @@ -0,0 +1,49 @@ +namespace Frank.CronJobs; + +/// +/// Provides methods to maintain and manage cron job schedules. +/// +public interface IScheduleMaintainer +{ + /// + /// Sets the cron expression for a specified cron job. + /// + /// The type of the cron job. + /// The cron expression to set. Must be a valid cron expression. + /// + /// Thrown when the provided cron expression is not valid. + /// + /// + /// Thrown when no cron job with the specified name is found. + /// + void SetSchedule(string cronExpression) where T : ICronJob; + + /// + /// Sets the time zone for a specified cron job. + /// + /// The type of the cron job. + /// The representing + /// the time zone to set. + /// + /// Thrown when no cron job with the specified name is found. + /// + void SetTimeZone(TimeZoneInfo timeZoneInfo) where T : ICronJob; + + /// + /// Stops a specified cron job. + /// + /// The type of the cron job to stop. + /// + /// Thrown when no cron job with the specified name is found. + /// + void Stop() where T : ICronJob; + + /// + /// Starts a specified cron job. + /// + /// The type of the cron job to start. + /// + /// Thrown when no cron job with the specified name is found. + /// + void Start() where T : ICronJob; +} \ No newline at end of file diff --git a/Frank.CronJobs/Internals/CronJobDescriptor.cs b/Frank.CronJobs/Internals/CronJobDescriptor.cs new file mode 100644 index 0000000..36ae7c6 --- /dev/null +++ b/Frank.CronJobs/Internals/CronJobDescriptor.cs @@ -0,0 +1,11 @@ +using Frank.Reflection; + +namespace Frank.CronJobs.Internals; + +internal class CronJobDescriptor(Type type, string cronExpression, bool running, TimeZoneInfo timeZoneInfo) : ICronJobDescriptor +{ + public string Name { get; } = type.GetFullDisplayName(); + public string Schedule { get; set; } = cronExpression; + public bool Running { get; set; } = running; + public TimeZoneInfo TimeZoneInfo { get; set; } = timeZoneInfo; +} \ No newline at end of file diff --git a/Frank.CronJobs/Internals/CronJobOptionsCollection.cs b/Frank.CronJobs/Internals/CronJobOptionsCollection.cs deleted file mode 100644 index 466f0ed..0000000 --- a/Frank.CronJobs/Internals/CronJobOptionsCollection.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace Frank.CronJobs.Internals; - -/// -/// Represents a collection of CronJobOptions. -/// -internal sealed class CronJobOptionsCollection : List -{ - public void Replace(CronJobOptions cronJobOptions) - { - if (this.Any(s => s.Name == cronJobOptions.Name)) - { - var index = this.FindIndex(s => s.Name == cronJobOptions.Name); - this[index] = cronJobOptions; - } - else - { - Add(cronJobOptions); - } - } - - - public void Replace(IEnumerable cronJobOptions) - { - foreach (var job in cronJobOptions) - Replace(job); - } - - public new void Add(CronJobOptions cronJobOptions) - { - if (this.Any(s => s.Name == cronJobOptions.Name)) - throw new ArgumentException($"CronJobOptions already exists. Name was: {cronJobOptions.Name}"); - - base.Add(cronJobOptions); - } - - public CronJobOptions Find(string jobName) => Find(service => service.Name == jobName) ?? throw new InvalidOperationException($"Cron job with name '{jobName}' not found."); -} \ No newline at end of file diff --git a/Frank.CronJobs/Internals/CronJobRunner.cs b/Frank.CronJobs/Internals/CronJobRunner.cs deleted file mode 100644 index 0e5e8d3..0000000 --- a/Frank.CronJobs/Internals/CronJobRunner.cs +++ /dev/null @@ -1,139 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2018 Marx J. Moura - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using Frank.CronJobs.Cron; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Frank.CronJobs.Internals; - -internal sealed class CronJobRunner : IHostedService -{ - private readonly List _timers = []; - private readonly IServiceScopeFactory _serviceScopeFactory; - private readonly ILogger _logger; - - private CronJobRunnerOptions _options; - - public CronJobRunner(IOptionsMonitor options, - IServiceScopeFactory serviceScopeFactory, ILogger logger) - { - _options = options?.CurrentValue ?? throw new ArgumentNullException(nameof(options)); - _serviceScopeFactory = serviceScopeFactory; - _logger = logger; - - options.OnChange(Restart); - } - - [SuppressMessage("ReSharper", "SuspiciousTypeConversion.Global")] - private async Task RunAsync(string jobName) - { - using var scope = _serviceScopeFactory.CreateScope(); - var jobInstance = scope.ServiceProvider.GetRequiredKeyedService(jobName); - - var stopwatch = Stopwatch.StartNew(); - - try - { - await jobInstance.RunAsync(); - stopwatch.Stop(); - } - catch (Exception error) - { - stopwatch.Stop(); - _logger.LogError(error, "Job '{ServiceTypeName}' failed during running", jobName); - } - finally - { - if (jobInstance is IDisposable disposable) - disposable.Dispose(); - if (jobInstance is IAsyncDisposable asyncDisposable) - await asyncDisposable.DisposeAsync(); - - _logger.LogDebug("Job '{ServiceTypeName}' finished running in {TimeElapsed}", jobName, stopwatch.Elapsed); - } - } - - private void Start() - { - if (!_options.Running) return; - - var timeZone = new TimeZoneOptions(_options.TimeZone).ToTimeZoneInfo(); - - foreach (var job in _options.Jobs) - { - if (!job.Running) - continue; - - var cron = new CronExpression(job.Cron); - - if (!cron.IsValid) - { - _logger.LogWarning("Invalid cron expression for '{JobName}'", job.Name); - continue; - } - - var timer = new JobInterval(cron, timeZone, async () => await RunAsync(job.Name)); - - _timers.Add(timer); - - timer.Run(); - } - } - - private void Stop() - { - foreach (var timer in _timers) - timer.Dispose(); - _timers.Clear(); - _logger.LogDebug("Cron job runner stopped"); - } - - private void Restart(CronJobRunnerOptions options) - { - _logger.LogWarning("Restarting cron job runner"); - _options = options; - Stop(); - Start(); - _logger.LogWarning("Cron job runner restarted"); - } - - Task IHostedService.StartAsync(CancellationToken cancellationToken) - { - _logger.LogDebug("Starting cron job runner"); - Start(); - return Task.CompletedTask; - } - - Task IHostedService.StopAsync(CancellationToken cancellationToken) - { - _logger.LogWarning("Stopping cron job runner"); - Stop(); - return Task.CompletedTask; - } -} diff --git a/Frank.CronJobs/Internals/CronJobRunnerOptions.cs b/Frank.CronJobs/Internals/CronJobRunnerOptions.cs deleted file mode 100644 index bd17a44..0000000 --- a/Frank.CronJobs/Internals/CronJobRunnerOptions.cs +++ /dev/null @@ -1,34 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2018 Marx J. Moura - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -namespace Frank.CronJobs.Internals; - -internal sealed class CronJobRunnerOptions -{ - public bool Running { get; set; } - - public string TimeZone { get; set; } = null!; - - public CronJobOptionsCollection Jobs { get; } = new(); -} \ No newline at end of file diff --git a/Frank.CronJobs/Internals/CronJobScheduler.cs b/Frank.CronJobs/Internals/CronJobScheduler.cs new file mode 100644 index 0000000..4094a9f --- /dev/null +++ b/Frank.CronJobs/Internals/CronJobScheduler.cs @@ -0,0 +1,83 @@ +using Frank.CronJobs.Cron; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Frank.CronJobs.Internals; + +internal class CronJobScheduler(IServiceScopeFactory serviceScopeFactory, ILogger logger, IEnumerable cronJobDescriptors, IScheduleMaintainer scheduleMaintainer) : IHostedService +{ + private readonly List _jobIntervals = new(); + private readonly ScheduleMaintainer _scheduleMaintainer = scheduleMaintainer as ScheduleMaintainer ?? throw new InvalidOperationException("ScheduleMaintainer is not of type ScheduleMaintainer"); + + public async Task StartAsync(CancellationToken cancellationToken) + { + logger.LogInformation("Starting cron job scheduler..."); + + logger.LogInformation("Found {Count} cron job descriptors", cronJobDescriptors.Count()); + + foreach (var descriptor in cronJobDescriptors.Where(descriptor => descriptor.Running)) + { + if (!CronHelper.IsValid(descriptor.Schedule)) + { + logger.LogWarning("Invalid cron expression for {DescriptorName}", descriptor.Name); + continue; + } + + logger.LogInformation("Starting cron job {DescriptorName}", descriptor.Name); + + var jobInterval = new JobInterval(descriptor, () => ExecuteJobAsync(descriptor, cancellationToken), cancellationToken); + _jobIntervals.Add(jobInterval); + jobInterval.Run(); + + _scheduleMaintainer.ScheduleChanged = changedDescriptor => + { + if (changedDescriptor.Name != descriptor.Name) + return; + if (!CronHelper.IsValid(changedDescriptor.Schedule)) + { + logger.LogWarning("Invalid cron expression for {DescriptorName}", changedDescriptor.Name); + return; + } + logger.LogInformation("Restarting cron job {DescriptorName}", changedDescriptor.Name); + jobInterval.Refresh(changedDescriptor); + }; + } + + await Task.CompletedTask; + } + + private async Task ExecuteJobAsync(ICronJobDescriptor descriptor, CancellationToken cancellationToken) + { + if (!descriptor.Running || !CronHelper.IsValid(descriptor.Schedule) || cancellationToken.IsCancellationRequested) + { + return; + } + await using var scope = serviceScopeFactory.CreateAsyncScope(); + using var loggingScope = logger.BeginScope("CronJob {JobTypeName} with schedule {Schedule} in timezone {TimeZone}", descriptor.Name, descriptor.Schedule, descriptor.TimeZoneInfo.Id); + + var jobInstance = scope.ServiceProvider.GetRequiredKeyedService(descriptor.Name); + + try + { + logger.LogInformation("Executing job {JobTypeName}", descriptor.Name); + await jobInstance.RunAsync(cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Error executing job {JobTypeName}", descriptor.Name); + } + } + + public Task StopAsync(CancellationToken cancellationToken) + { + logger.LogInformation("Stopping cron job scheduler..."); + foreach (var jobInterval in _jobIntervals) + { + jobInterval.Dispose(); + } + _jobIntervals.Clear(); + + return Task.CompletedTask; + } +} diff --git a/Frank.CronJobs/Internals/CronJobsBuilder.cs b/Frank.CronJobs/Internals/CronJobsBuilder.cs deleted file mode 100644 index 46e2d02..0000000 --- a/Frank.CronJobs/Internals/CronJobsBuilder.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Frank.CronJobs.Cron; -using Frank.Reflection; -using Microsoft.Extensions.DependencyInjection; - -namespace Frank.CronJobs.Internals; - -internal sealed class CronJobsBuilder(IServiceCollection services, CronJobRunnerOptions options) : ICronJobsBuilder -{ - public ICronJobsBuilder AddCronJob(string cron) where T : class, ICronJob => AddCronJob(new CronExpression(cron)); - - public ICronJobsBuilder AddCronJob(CronExpression cronExpression) where T : class, ICronJob => AddCronJob(jobOptions => - { - jobOptions.Cron = cronExpression.ToString(); - jobOptions.Running = true; - }); - - public ICronJobsBuilder AddCronJob(Action configure) where T : class, ICronJob - { - var jobOptions = new CronJobOptions(); - configure(jobOptions); - return AddCronJob(jobOptions); - } - - public ICronJobsBuilder AddCronJob(CronJobOptions jobOptions) where T : class, ICronJob - { - var serviceName = typeof(T).GetDisplayName(); - var service = new ServiceDescriptor(typeof(ICronJob), serviceName, typeof(T), ServiceLifetime.Scoped); - jobOptions.Name = serviceName; - - options.Jobs.Add(jobOptions); - services.Add(service); - - return this; - } -} \ No newline at end of file diff --git a/Frank.CronJobs/Internals/ICronJobDescriptor.cs b/Frank.CronJobs/Internals/ICronJobDescriptor.cs new file mode 100644 index 0000000..f53c1a2 --- /dev/null +++ b/Frank.CronJobs/Internals/ICronJobDescriptor.cs @@ -0,0 +1,9 @@ +namespace Frank.CronJobs.Internals; + +internal interface ICronJobDescriptor +{ + string Name { get; } + string Schedule { get; set; } + bool Running { get; set; } + TimeZoneInfo TimeZoneInfo { get; set; } +} \ No newline at end of file diff --git a/Frank.CronJobs/Internals/JobInterval.cs b/Frank.CronJobs/Internals/JobInterval.cs index 28136cd..d838519 100644 --- a/Frank.CronJobs/Internals/JobInterval.cs +++ b/Frank.CronJobs/Internals/JobInterval.cs @@ -1,58 +1,58 @@ -/* - * MIT License - * - * Copyright (c) 2018 Marx J. Moura - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -using System.Reactive.Linq; +using System.Diagnostics.CodeAnalysis; using Frank.CronJobs.Cron; namespace Frank.CronJobs.Internals; -internal sealed class JobInterval(CronExpression cron, TimeZoneInfo timezone, Func work) : IDisposable +internal sealed class JobInterval(ICronJobDescriptor descriptor, Func work, CancellationToken cancellationToken) : IDisposable { - private readonly CronExpression _cron = cron ?? throw new ArgumentNullException(nameof(cron)); - private readonly TimeZoneInfo _timezone = timezone ?? throw new ArgumentNullException(nameof(timezone)); - private readonly Func _work = work ?? throw new ArgumentNullException(nameof(work)); - - private IDisposable _subscription = null!; - - public void Dispose() => _subscription.Dispose(); + private Timer? _timer; + public void Dispose() + { + _timer?.Dispose(); + } + + [SuppressMessage("ReSharper", "TailRecursiveCall")] public void Run() { - var now = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _timezone); - var nextTime = _cron.Next(now); + var now = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, descriptor.TimeZoneInfo); + var cronExpression = new CronExpression(descriptor.Schedule); + var nextTime = cronExpression.Next(now); + // If no next time is found, do not schedule further executions if (nextTime == DateTime.MinValue) return; var interval = nextTime - now; + if (interval <= TimeSpan.Zero) + { + // If the calculated interval is in the past, schedule immediately for the next possible interval + Run(); + return; + } + + _timer = new Timer(async _ => + { + try + { + await work(); + } + catch + { + // Ignore exceptions + } + + // Reschedule the next run after the current work is completed + Run(); + }, null, interval, Timeout.InfiniteTimeSpan); // Timeout.InfiniteTimeSpan prevents periodic signalling + + cancellationToken.Register(() => _timer?.Dispose()); + } - _subscription = Observable.Timer(interval) - .Select(tick => Observable.FromAsync(_work)) - .Concat() - .Subscribe( - onNext: tick => { /* noop */ }, - onCompleted: Run - ); + public void Refresh(ICronJobDescriptor cronJobDescriptor) + { + _timer?.Change(Timeout.Infinite, Timeout.Infinite); + descriptor = cronJobDescriptor; + Run(); } -} +} \ No newline at end of file diff --git a/Frank.CronJobs/Internals/ScheduleMaintainer.cs b/Frank.CronJobs/Internals/ScheduleMaintainer.cs new file mode 100644 index 0000000..d2c35ae --- /dev/null +++ b/Frank.CronJobs/Internals/ScheduleMaintainer.cs @@ -0,0 +1,60 @@ +using Frank.CronJobs.Cron; +using Frank.Reflection; +using Microsoft.Extensions.DependencyInjection; + +namespace Frank.CronJobs.Internals; + +internal class ScheduleMaintainer(IServiceProvider serviceProvider) : IScheduleMaintainer +{ + /// + public void SetSchedule(string cronExpression) where T : ICronJob + { + if (!CronHelper.IsValid(cronExpression)) + throw new InvalidCronExpressionException(cronExpression); + var descriptors = serviceProvider.GetServices(); + var descriptor = descriptors.FirstOrDefault(d => d.Name == typeof(T).GetFullDisplayName()); + if (descriptor is null) + throw new InvalidOperationException($"No cron job with name {typeof(T).GetFullDisplayName()} found"); + descriptor.Schedule = cronExpression; + + ScheduleChanged?.Invoke(descriptor); + } + + /// + public void SetTimeZone(TimeZoneInfo timeZoneInfo) where T : ICronJob + { + var descriptors = serviceProvider.GetServices(); + var descriptor = descriptors.FirstOrDefault(d => d.Name == typeof(T).GetFullDisplayName()); + if (descriptor is null) + throw new InvalidOperationException($"No cron job with name {typeof(T).GetFullDisplayName()} found"); + descriptor.TimeZoneInfo = timeZoneInfo; + + ScheduleChanged?.Invoke(descriptor); + } + + /// + public void Stop() where T : ICronJob + { + var descriptors = serviceProvider.GetServices(); + var descriptor = descriptors.FirstOrDefault(d => d.Name == typeof(T).GetFullDisplayName()); + if (descriptor is null) + throw new InvalidOperationException($"No cron job with name {typeof(T).GetFullDisplayName()} found"); + descriptor.Running = false; + + ScheduleChanged?.Invoke(descriptor); + } + + /// + public void Start() where T : ICronJob + { + var descriptors = serviceProvider.GetServices(); + var descriptor = descriptors.FirstOrDefault(d => d.Name == typeof(T).GetFullDisplayName()); + if (descriptor is null) + throw new InvalidOperationException($"No cron job with name {typeof(T).GetFullDisplayName()} found"); + descriptor.Running = true; + + ScheduleChanged?.Invoke(descriptor); + } + + public Action? ScheduleChanged; +} \ No newline at end of file diff --git a/Frank.CronJobs/InvalidCronExpressionException.cs b/Frank.CronJobs/InvalidCronExpressionException.cs new file mode 100644 index 0000000..8949223 --- /dev/null +++ b/Frank.CronJobs/InvalidCronExpressionException.cs @@ -0,0 +1,6 @@ +namespace Frank.CronJobs; + +public class InvalidCronExpressionException(string cronExpression) : Exception +{ + public string InvalidCronExpression { get; private set; } = cronExpression; +} \ No newline at end of file diff --git a/Frank.CronJobs/ServiceCollectionExtensions.cs b/Frank.CronJobs/ServiceCollectionExtensions.cs index 930f6ab..4fb2feb 100644 --- a/Frank.CronJobs/ServiceCollectionExtensions.cs +++ b/Frank.CronJobs/ServiceCollectionExtensions.cs @@ -1,63 +1,58 @@ -/* - * MIT License - * - * Copyright (c) 2018 Marx J. Moura - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - +using Frank.CronJobs.Cron; using Frank.CronJobs.Internals; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace Frank.CronJobs; -/// -/// Provides extension methods for the IServiceCollection class. -/// public static class ServiceCollectionExtensions { /// - /// Adds cron jobs to the specified service collection using the provided configuration and builder action. + /// Adds a cron job to the service collection. /// - /// The service collection to add the cron jobs to. - /// The configuration containing the cron job options. - /// An action to configure the cron job builder. - public static void AddCronJobs(this IServiceCollection services, IConfiguration configuration, Action builderAction) + /// The type of the cron job. + /// The service collection. + /// The cron expression. + /// The IANA name of the time zone. + /// Whether the cron job should be running or not. Default is true. + public static void AddCronJob(this IServiceCollection services, string cronExpression, string timeZoneIanaName, bool running = true) where TCronJob : class, ICronJob { - var options = new CronJobRunnerOptions(); - configuration.Bind(nameof(CronJobRunnerOptions), options); - var tempOptionsCollection = new CronJobOptionsCollection(); - tempOptionsCollection.AddRange(options.Jobs); - - var builder = new CronJobsBuilder(services, options); - builderAction(builder); + var timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneIanaName); + AddCronJob(services, cronExpression, running, timeZone); + } + + /// + /// Adds a cron job to the service collection. + /// + /// The type of the cron job. + /// The service collection to add the cron job to. + /// The cron expression that defines the schedule of the job. + /// A flag indicating whether the cron job should initially be running. The default value is true. + /// The time zone for the cron job's schedule. The default value is null, which represents the UTC time zone. + /// Thrown when the provided cron expression is invalid. + public static void AddCronJob(this IServiceCollection services, string cronExpression, bool running = true, TimeZoneInfo? timeZone = null) where TCronJob : class, ICronJob + { + timeZone ??= TimeZoneInfo.Utc; + + if (!CronHelper.IsValid(cronExpression)) + { + throw new InvalidCronExpressionException(cronExpression); + } - options.Jobs.Replace(tempOptionsCollection); + var descriptor = new CronJobDescriptor(typeof(TCronJob), cronExpression, running, timeZone); + + // Add the descriptor for this cron job + services.AddSingleton(descriptor); + + // Register the cron job service + services.AddKeyedSingleton(descriptor.Name); - services.Configure(c => - { - c.Jobs.Replace(options.Jobs); - c.TimeZone = options.TimeZone; - c.Running = options.Running; - }); + // Register the cron job scheduler if not already present as a hosted service + if (!services.Any(d => d.ServiceType == typeof(IHostedService) && d.ImplementationType == typeof(CronJobScheduler))) + services.AddHostedService(); - services.AddHostedService(); + // Register the schedule maintainer if not already present + if (services.All(d => d.ServiceType != typeof(IScheduleMaintainer))) + services.AddSingleton(); } } \ No newline at end of file diff --git a/Frank.CronJobs/TimeZoneOptions.cs b/Frank.CronJobs/TimeZoneOptions.cs deleted file mode 100644 index 38c17d7..0000000 --- a/Frank.CronJobs/TimeZoneOptions.cs +++ /dev/null @@ -1,50 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2018 Marx J. Moura - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -using System.Text.RegularExpressions; - -namespace Frank.CronJobs; - -public sealed partial class TimeZoneOptions(string timeZone) -{ - public TimeZoneInfo ToTimeZoneInfo() - { - if (string.IsNullOrWhiteSpace(timeZone)) - return TimeZoneInfo.Utc; - if (!UtcOffsetRegex().IsMatch(timeZone)) - return TimeZoneInfo.FindSystemTimeZoneById(timeZone); - return TimeZoneInfo.CreateCustomTimeZone( - id: "CronQuery", - baseUtcOffset: TimeSpan.Parse(UtcOffsetFallbackRegex().Replace(timeZone, string.Empty)), - displayName: $"({timeZone}) CronQuery", - standardDisplayName: "CronQuery Custom Time" - ); - } - - [GeneratedRegex(@"^UTC[+-]\d{2}:\d{2}$")] - private static partial Regex UtcOffsetRegex(); - - [GeneratedRegex("UTC[+]?")] - private static partial Regex UtcOffsetFallbackRegex(); -} diff --git a/README.md b/README.md index 365835f..dc95d02 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,84 @@ ___ ___ ## Usage + +This library is designed to be used with the `Microsoft.Extensions.DependencyInjection` library. It is designed to be used +list this in your `Program.cs` file: + ```c# using Frank.CronJobs; -var cron = new CronJob("* * * * *"); -var next = cron.GetNextOccurrence(DateTime.Now); +var services = new ServiceCollection(); +services.AddCronJob("0 15 10 * * ?"); + +var serviceProvider = services.BuildServiceProvider(); + +await serviceProvider.StartAsync(); +``` + +This will start the job at 10:15 AM every day. The `MyJob` class should implement the `ICronJob` interface, and the +`ExecuteAsync` method should be implemented. This method will be called every time the cron expression is satisfied. + +If you want to stop or modify the job, you can use the `IScheuleMaintainer` interface to stop, start, or modify any job at +runtime like this: + +```c# +var maintainer = serviceProvider.GetRequiredService(); +maintainer.Stop(); ``` +This will stop the job from running. You can also start it again by calling `Start()`. You can also modify the +schedule and timezones of the job by calling `SetSchedule("0 15 10 * * ?")` and `SetTimeZone +("America/New_York")` respectively. + +The job runner is added as a hosted service, so it will run as long as the application is running. + +The scenario for editing the schedule at runtime is meant to make it flexible for the user to change the schedule of the +job without having to restart the application, or specify the schedule in a configuration file, and so anything that can +have the opportunity y to change the schedule at runtime. A service that react to IOptions changes, for example, can +look like this: + +```c# +public class MyService +{ + private readonly IOptionsMonitor _optionsMonitor; + private readonly IScheduleMaintainer _scheduleMaintainer; + + public MyService(IOptionsMonitor optionsMonitor, IScheduleMaintainer scheduleMaintainer) + { + _optionsMonitor = optionsMonitor; + _scheduleMaintainer = scheduleMaintainer; + _optionsMonitor.OnChange(OnOptionsChange); + } + + private void OnOptionsChange(MyOptions options) + { + _scheduleMaintainer.SetSchedule(options.CronExpression); + // etc. + } +} +``` + + +## Installation + +Install the NuGet package directly from the package manager console: + +```powershell +PM> Install-Package Frank.CronJobs +``` + +## License + +Frank.CronJobs is licensed under the [MIT license](LICENSE). + +## Contributing + +Contributions are welcome, please submit a pull request after you create an issue if you have any ideas or find any +bugs. Major changes should be discussed in an issue before submitting a pull request. Also, no new dependencies unless +discussed and agreed upon in an issue first. + +## Credits + +This library is based on [CronQuery](https://github.com/marxjmoura/cronquery), which I am a contributor to. This is built on +that code for the basic cron functionality, and some patterns and ideas are borrowed from that project. \ No newline at end of file