diff --git a/samples/BackgroundJobs.WorkerService/CronJob.cs b/samples/BackgroundJobs.WorkerService/CronJob.cs new file mode 100644 index 0000000..38ee5a4 --- /dev/null +++ b/samples/BackgroundJobs.WorkerService/CronJob.cs @@ -0,0 +1,22 @@ +using Cronos; +using Pilgaard.BackgroundJobs; + +namespace BackgroundJobs.WorkerService; + +public class CronJob : ICronJob +{ + private readonly ILogger _logger; + public CronJob(ILogger logger) + { + _logger = logger; + } + + public Task RunJobAsync(CancellationToken cancellationToken = default) + { + _logger.LogInformation("{jobName} executed at {now:G}", nameof(CronJob), DateTime.Now); + + return Task.CompletedTask; + } + + public CronExpression CronExpression => CronExpression.Parse("* * * * *"); +} diff --git a/samples/BackgroundJobs.WorkerService/OneTimeJob.cs b/samples/BackgroundJobs.WorkerService/OneTimeJob.cs new file mode 100644 index 0000000..400d8f1 --- /dev/null +++ b/samples/BackgroundJobs.WorkerService/OneTimeJob.cs @@ -0,0 +1,22 @@ +using Pilgaard.BackgroundJobs; + +namespace BackgroundJobs.WorkerService; + +public class OneTimeJob : IOneTimeJob +{ + private readonly ILogger _logger; + private static readonly DateTime _utcNowAtStartup = DateTime.UtcNow; + public OneTimeJob(ILogger logger) + { + _logger = logger; + } + + public Task RunJobAsync(CancellationToken cancellationToken = default) + { + _logger.LogInformation("{jobName} executed at {now:G}", nameof(OneTimeJob), DateTime.Now); + + return Task.CompletedTask; + } + + public DateTime ScheduledTimeUtc => _utcNowAtStartup.AddMinutes(1); +} diff --git a/samples/BackgroundJobs.WorkerService/Program.cs b/samples/BackgroundJobs.WorkerService/Program.cs index 7590ee5..0b61345 100644 --- a/samples/BackgroundJobs.WorkerService/Program.cs +++ b/samples/BackgroundJobs.WorkerService/Program.cs @@ -1,12 +1,15 @@ using BackgroundJobs.WorkerService; using Pilgaard.BackgroundJobs; -IHost host = Host.CreateDefaultBuilder(args) +Host.CreateDefaultBuilder(args) .ConfigureServices(services => { services.AddBackgroundJobs() - .AddJob(nameof(RecurringJob)); + .AddJob() + .AddJob() + .AddJob() + .AddJob() + .AddJob() + .AddJob(); }) - .Build(); - -host.Run(); + .Build().Run(); diff --git a/samples/BackgroundJobs.WorkerService/RecurringJob.cs b/samples/BackgroundJobs.WorkerService/RecurringJobEvery10Minutes.cs similarity index 58% rename from samples/BackgroundJobs.WorkerService/RecurringJob.cs rename to samples/BackgroundJobs.WorkerService/RecurringJobEvery10Minutes.cs index f1a0d49..b6d442c 100644 --- a/samples/BackgroundJobs.WorkerService/RecurringJob.cs +++ b/samples/BackgroundJobs.WorkerService/RecurringJobEvery10Minutes.cs @@ -1,21 +1,21 @@ -using Pilgaard.BackgroundJobs; - -namespace BackgroundJobs.WorkerService; - -public class RecurringJob : IRecurringJob -{ - private readonly ILogger _logger; - public RecurringJob(ILogger logger) - { - _logger = logger; - } - - public Task RunJobAsync(CancellationToken cancellationToken = default) - { - _logger.LogInformation("{jobName} executed at {now:G}", nameof(RecurringJob), DateTime.Now); - - return Task.CompletedTask; - } - - public TimeSpan Interval => TimeSpan.FromMinutes(10); -} +using Pilgaard.BackgroundJobs; + +namespace BackgroundJobs.WorkerService; + +public class RecurringJobEvery10Minutes : IRecurringJob +{ + private readonly ILogger _logger; + public RecurringJobEvery10Minutes(ILogger logger) + { + _logger = logger; + } + + public Task RunJobAsync(CancellationToken cancellationToken = default) + { + _logger.LogInformation("{jobName} executed at {now:G}", nameof(RecurringJobEvery10Minutes), DateTime.Now); + + return Task.CompletedTask; + } + + public TimeSpan Interval => TimeSpan.FromMinutes(10); +} diff --git a/samples/BackgroundJobs.WorkerService/RecurringJobEvery1Minute.cs b/samples/BackgroundJobs.WorkerService/RecurringJobEvery1Minute.cs new file mode 100644 index 0000000..f279f7c --- /dev/null +++ b/samples/BackgroundJobs.WorkerService/RecurringJobEvery1Minute.cs @@ -0,0 +1,21 @@ +using Pilgaard.BackgroundJobs; + +namespace BackgroundJobs.WorkerService; + +public class RecurringJobEvery1Minute : IRecurringJob +{ + private readonly ILogger _logger; + public RecurringJobEvery1Minute(ILogger logger) + { + _logger = logger; + } + + public Task RunJobAsync(CancellationToken cancellationToken = default) + { + _logger.LogInformation("{jobName} executed at {now:G}", nameof(RecurringJobEvery1Minute), DateTime.Now); + + return Task.CompletedTask; + } + + public TimeSpan Interval => TimeSpan.FromMinutes(1); +} diff --git a/samples/BackgroundJobs.WorkerService/RecurringJobEvery30Minutes.cs b/samples/BackgroundJobs.WorkerService/RecurringJobEvery30Minutes.cs new file mode 100644 index 0000000..d97da9b --- /dev/null +++ b/samples/BackgroundJobs.WorkerService/RecurringJobEvery30Minutes.cs @@ -0,0 +1,21 @@ +using Pilgaard.BackgroundJobs; + +namespace BackgroundJobs.WorkerService; + +public class RecurringJobEvery30Minutes : IRecurringJob +{ + private readonly ILogger _logger; + public RecurringJobEvery30Minutes(ILogger logger) + { + _logger = logger; + } + + public Task RunJobAsync(CancellationToken cancellationToken = default) + { + _logger.LogInformation("{jobName} executed at {now:G}", nameof(RecurringJobEvery30Minutes), DateTime.Now); + + return Task.CompletedTask; + } + + public TimeSpan Interval => TimeSpan.FromMinutes(30); +} diff --git a/samples/BackgroundJobs.WorkerService/RecurringJobEvery5Minutes.cs b/samples/BackgroundJobs.WorkerService/RecurringJobEvery5Minutes.cs new file mode 100644 index 0000000..9cc2225 --- /dev/null +++ b/samples/BackgroundJobs.WorkerService/RecurringJobEvery5Minutes.cs @@ -0,0 +1,21 @@ +using Pilgaard.BackgroundJobs; + +namespace BackgroundJobs.WorkerService; + +public class RecurringJobEvery5Minutes : IRecurringJob +{ + private readonly ILogger _logger; + public RecurringJobEvery5Minutes(ILogger logger) + { + _logger = logger; + } + + public Task RunJobAsync(CancellationToken cancellationToken = default) + { + _logger.LogInformation("{jobName} executed at {now:G}", nameof(RecurringJobEvery5Minutes), DateTime.Now); + + return Task.CompletedTask; + } + + public TimeSpan Interval => TimeSpan.FromMinutes(5); +} diff --git a/src/Pilgaard.BackgroundJobs/BackgroundJobScheduler.cs b/src/Pilgaard.BackgroundJobs/BackgroundJobScheduler.cs index 3543fa4..2144daa 100644 --- a/src/Pilgaard.BackgroundJobs/BackgroundJobScheduler.cs +++ b/src/Pilgaard.BackgroundJobs/BackgroundJobScheduler.cs @@ -30,17 +30,14 @@ public BackgroundJobScheduler(IServiceScopeFactory scopeFactory, _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + if (registrationsValidator is null) + { + throw new ArgumentNullException(nameof(registrationsValidator)); + } + registrationsValidator.Validate(_options.Value.Registrations); } - /// - /// Asynchronously retrieves an ordered enumerable of background job registrations. - /// - /// Each background job registration is returned when it should be run. - /// - /// - /// The used for cancelling the enumeration. - /// An asynchronous enumerable of background job registrations. public async IAsyncEnumerable GetBackgroundJobsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) { var interval = TimeSpan.FromSeconds(30); @@ -53,7 +50,7 @@ public async IAsyncEnumerable GetBackgroundJobsAsync( { var intervalMinus5Seconds = interval.Subtract(TimeSpan.FromSeconds(5)); - _logger.LogDebug("No background job occurrences found in the TimeSpan {interval}, " + + _logger.LogDebug("No CronJob or OneTimeJob occurrences found in the TimeSpan {interval}, " + "waiting for TimeSpan {interval} until checking again.", interval, intervalMinus5Seconds); await Task.Delay(intervalMinus5Seconds, cancellationToken); @@ -78,6 +75,9 @@ public async IAsyncEnumerable GetBackgroundJobsAsync( } } + + public IEnumerable GetRecurringJobs() => _options.Value.Registrations.Where(registration => registration.IsRecurringJob); + /// /// Gets an ordered enumerable of background job occurrences within the specified . /// @@ -90,7 +90,8 @@ internal IEnumerable GetOrderedBackgroundJobOccurrences using var scope = _scopeFactory.CreateScope(); var backgroundJobOccurrences = new List(); - foreach (var registration in _options.Value.Registrations) + + foreach (var registration in _options.Value.Registrations.Where(registration => !registration.IsRecurringJob)) { var backgroundJob = registration.Factory(scope.ServiceProvider); diff --git a/src/Pilgaard.BackgroundJobs/BackgroundJobService.cs b/src/Pilgaard.BackgroundJobs/BackgroundJobService.cs index f4b73ae..74e6545 100644 --- a/src/Pilgaard.BackgroundJobs/BackgroundJobService.cs +++ b/src/Pilgaard.BackgroundJobs/BackgroundJobService.cs @@ -29,6 +29,9 @@ internal sealed class BackgroundJobService : IBackgroundJobService private readonly ILogger _logger; private readonly IBackgroundJobScheduler _backgroundJobScheduler; + private event Func? RecurringJobTimerTriggered; + private static readonly List _recurringJobTimers = new(); + private static readonly Meter _meter = new( name: typeof(BackgroundJobService).Assembly.GetName().Name!, version: typeof(BackgroundJobService).Assembly.GetName().Version?.ToString()); @@ -66,6 +69,8 @@ public BackgroundJobService( /// public async Task RunJobsAsync(CancellationToken cancellationToken = default) { + ScheduleRecurringJobs(cancellationToken); + while (!cancellationToken.IsCancellationRequested) { _logger.LogDebug("Scheduling background jobs."); @@ -83,6 +88,56 @@ public async Task RunJobsAsync(CancellationToken cancellationToken = default) } } + internal void ScheduleRecurringJobs(CancellationToken cancellationToken) + { + var recurringJobRegistrations = _backgroundJobScheduler.GetRecurringJobs(); + if (recurringJobRegistrations.Any()) + { + RecurringJobTimerTriggered += RunRecurringJobAsync; + } + + using var scope = _scopeFactory.CreateScope(); + foreach (var jobRegistration in recurringJobRegistrations) + { + if (jobRegistration.Factory(scope.ServiceProvider) is not IRecurringJob recurringJob) + { + _logger.LogError("Failed to schedule recurring job {@jobRegistration}. " + + "It does not implement {recurringJobInterface}", + jobRegistration, typeof(IRecurringJob)); + continue; + } + + var dueTime = recurringJob switch + { + IRecurringJobWithInitialDelay recurringJobWithInitialDelay => recurringJobWithInitialDelay.InitialDelay, + _ => recurringJob.Interval + }; + + var recurringJobTimer = new System.Threading.Timer(_ => RecurringJobTimerTriggered?.Invoke(this, EventArgs.Empty, jobRegistration, cancellationToken), + state: null, + dueTime: dueTime, + period: recurringJob.Interval); + + _recurringJobTimers.Add(recurringJobTimer); + + _logger.LogInformation("RecurringJob {jobName} has been scheduled to run every {interval}. " + + "The first run will be in {dueTime}", + jobRegistration.Name, recurringJob.Interval, dueTime); + } + } + +#pragma warning disable IDE0060 + /// + /// Runs the recurring job. + /// + /// The sender. This is not used. + /// The instance containing the event data. This is not used. + /// The background job registration. + /// A which can be used to cancel the background job. + internal async Task RunRecurringJobAsync(object sender, EventArgs eventArgs, BackgroundJobRegistration registration, CancellationToken cancellationToken) + => await RunJobAsync(registration, cancellationToken); +#pragma warning restore IDE0060 + /// /// Constructs the background job using and runs it. /// @@ -137,4 +192,12 @@ internal async Task RunJobAsync(BackgroundJobRegistration registration, Cancella timeoutCancellationTokenSource?.Dispose(); } } + + public void Dispose() + { + foreach (var disposable in _recurringJobTimers) + { + disposable.Dispose(); + } + } } diff --git a/src/Pilgaard.BackgroundJobs/IBackgroundJobScheduler.cs b/src/Pilgaard.BackgroundJobs/IBackgroundJobScheduler.cs index adb85d6..5f76445 100644 --- a/src/Pilgaard.BackgroundJobs/IBackgroundJobScheduler.cs +++ b/src/Pilgaard.BackgroundJobs/IBackgroundJobScheduler.cs @@ -9,10 +9,19 @@ internal interface IBackgroundJobScheduler /// /// Asynchronously retrieves an ordered enumerable of background job registrations. /// + /// Jobs that implement are not retrieved, they are scheduled during startup. + /// + /// /// Each background job registration is returned when it should be run. /// /// /// The used for cancelling the enumeration. /// An asynchronous enumerable of background job registrations. IAsyncEnumerable GetBackgroundJobsAsync(CancellationToken cancellationToken); + + /// + /// Retrieves all s where is true + /// + /// An enumerable of background job registrations where is true + IEnumerable GetRecurringJobs(); } diff --git a/src/Pilgaard.BackgroundJobs/IBackgroundJobService.cs b/src/Pilgaard.BackgroundJobs/IBackgroundJobService.cs index 64b425f..d3a3ef7 100644 --- a/src/Pilgaard.BackgroundJobs/IBackgroundJobService.cs +++ b/src/Pilgaard.BackgroundJobs/IBackgroundJobService.cs @@ -21,7 +21,7 @@ namespace Pilgaard.BackgroundJobs; /// . /// /// -public interface IBackgroundJobService +public interface IBackgroundJobService : IDisposable { /// /// Runs all the background jobs in the application. diff --git a/src/Pilgaard.BackgroundJobs/Jobs/DelegateRecurringJobWithInitialDelay.cs b/src/Pilgaard.BackgroundJobs/Jobs/DelegateRecurringJobWithInitialDelay.cs new file mode 100644 index 0000000..f467143 --- /dev/null +++ b/src/Pilgaard.BackgroundJobs/Jobs/DelegateRecurringJobWithInitialDelay.cs @@ -0,0 +1,25 @@ +namespace Pilgaard.BackgroundJobs; + +/// +/// A simple implementation of which uses a provided delegate to +/// implement the job. +/// +internal sealed class DelegateRecurringJobWithInitialDelay : IRecurringJobWithInitialDelay +{ + private readonly Func _job; + + public TimeSpan Interval { get; } + + public TimeSpan InitialDelay { get; } + + public DelegateRecurringJobWithInitialDelay(Func job, TimeSpan interval, TimeSpan initialDelay) + { + _job = job ?? throw new ArgumentNullException(nameof(job)); + Interval = interval; + InitialDelay = initialDelay; + } + + public Task RunJobAsync(CancellationToken cancellationToken = default) + => _job(cancellationToken); + +} \ No newline at end of file diff --git a/src/Pilgaard.BackgroundJobs/Jobs/IRecurringJobWithInitialDelay.cs b/src/Pilgaard.BackgroundJobs/Jobs/IRecurringJobWithInitialDelay.cs new file mode 100644 index 0000000..98361fc --- /dev/null +++ b/src/Pilgaard.BackgroundJobs/Jobs/IRecurringJobWithInitialDelay.cs @@ -0,0 +1,15 @@ +namespace Pilgaard.BackgroundJobs; + +/// +/// This interface represents a background job that runs at a specified interval, after an initial delay. +/// +public interface IRecurringJobWithInitialDelay : IRecurringJob +{ + /// + /// The initial delay before triggering the first time. + /// + /// Setting this to triggers it immediately on startup. + /// + /// + TimeSpan InitialDelay { get; } +} diff --git a/src/Pilgaard.BackgroundJobs/Jobs/OneTimeJobExtensions.cs b/src/Pilgaard.BackgroundJobs/Jobs/OneTimeJobExtensions.cs index d7e72d1..979886e 100644 --- a/src/Pilgaard.BackgroundJobs/Jobs/OneTimeJobExtensions.cs +++ b/src/Pilgaard.BackgroundJobs/Jobs/OneTimeJobExtensions.cs @@ -7,13 +7,10 @@ public static class OneTimeJobExtensions /// /// The one-time job to get the next occurrence of. /// The next (and only) occurrence of the one-time job, or null if the occurrence is in the past. - public static DateTime? GetNextOccurrence(this IOneTimeJob oneTimeJob) - { - if (DateTime.UtcNow > oneTimeJob.ScheduledTimeUtc) - return null; - - return oneTimeJob.ScheduledTimeUtc; - } + public static DateTime? GetNextOccurrence(this IOneTimeJob oneTimeJob) => + DateTime.UtcNow > oneTimeJob.ScheduledTimeUtc + ? null + : oneTimeJob.ScheduledTimeUtc; /// /// Get the next occurrence of the one-time job, as an . @@ -25,12 +22,13 @@ public static IEnumerable GetOccurrences(this IOneTimeJob oneTimeJob, { // If toUtc is less than the scheduled time, it's not within the range of occurrences to return if (toUtc < oneTimeJob.ScheduledTimeUtc) + { return Enumerable.Empty(); + } // If the current time is higher than the scheduled time, there is no next occurrence - if (DateTime.UtcNow > oneTimeJob.ScheduledTimeUtc) - return Enumerable.Empty(); - - return new[] { oneTimeJob.ScheduledTimeUtc }; + return DateTime.UtcNow > oneTimeJob.ScheduledTimeUtc + ? Enumerable.Empty() + : new[] { oneTimeJob.ScheduledTimeUtc }; } } diff --git a/src/Pilgaard.BackgroundJobs/Registration/BackgroundJobRegistration.cs b/src/Pilgaard.BackgroundJobs/Registration/BackgroundJobRegistration.cs index c15c8af..5651d74 100644 --- a/src/Pilgaard.BackgroundJobs/Registration/BackgroundJobRegistration.cs +++ b/src/Pilgaard.BackgroundJobs/Registration/BackgroundJobRegistration.cs @@ -11,20 +11,25 @@ public sealed class BackgroundJobRegistration /// The instance of the background job. /// The name of the background job. /// The timeout for the background job. (optional) + /// Whether the implements or not. public BackgroundJobRegistration( IBackgroundJob instance, string name, - TimeSpan? timeout = null) + TimeSpan? timeout = null, + bool isRecurringJob = false) { if (timeout <= TimeSpan.Zero && timeout != System.Threading.Timeout.InfiniteTimeSpan) + { throw new ArgumentOutOfRangeException(nameof(timeout)); - + } Name = name ?? throw new ArgumentNullException(nameof(name)); Factory = (_) => instance; Timeout = timeout ?? System.Threading.Timeout.InfiniteTimeSpan; + + IsRecurringJob = isRecurringJob; } /// @@ -33,19 +38,25 @@ public BackgroundJobRegistration( /// The factory used to create the background job instance. /// The name of the background job. /// The timeout for the background job. (optional) + /// Whether the implements or not. public BackgroundJobRegistration( Func factory, string name, - TimeSpan? timeout = null) + TimeSpan? timeout = null, + bool isRecurringJob = false) { if (timeout <= TimeSpan.Zero && timeout != System.Threading.Timeout.InfiniteTimeSpan) + { throw new ArgumentOutOfRangeException(nameof(timeout)); + } Name = name ?? throw new ArgumentNullException(nameof(name)); Factory = factory ?? throw new ArgumentNullException(nameof(factory)); Timeout = timeout ?? System.Threading.Timeout.InfiniteTimeSpan; + + IsRecurringJob = isRecurringJob; } /// @@ -62,4 +73,9 @@ public BackgroundJobRegistration( /// Gets the timeout used for the job. /// public TimeSpan Timeout { get; } + + /// + /// Gets a value indicating whether this instance implements or not. + /// + internal bool IsRecurringJob { get; } = false; } diff --git a/src/Pilgaard.BackgroundJobs/Registration/BackgroundJobsBuilder.cs b/src/Pilgaard.BackgroundJobs/Registration/BackgroundJobsBuilder.cs index f4dd1fc..43d31bf 100644 --- a/src/Pilgaard.BackgroundJobs/Registration/BackgroundJobsBuilder.cs +++ b/src/Pilgaard.BackgroundJobs/Registration/BackgroundJobsBuilder.cs @@ -29,7 +29,9 @@ public BackgroundJobsBuilder(IServiceCollection services) public IBackgroundJobsBuilder Add(BackgroundJobRegistration registration) { if (registration == null) + { throw new ArgumentNullException(nameof(registration)); + } Services.Configure(options => options.Registrations.Add(registration)); diff --git a/src/Pilgaard.BackgroundJobs/Registration/BackgroundJobsBuilderDelegateExtensions.cs b/src/Pilgaard.BackgroundJobs/Registration/BackgroundJobsBuilderDelegateExtensions.cs index 9217322..07b31eb 100644 --- a/src/Pilgaard.BackgroundJobs/Registration/BackgroundJobsBuilderDelegateExtensions.cs +++ b/src/Pilgaard.BackgroundJobs/Registration/BackgroundJobsBuilderDelegateExtensions.cs @@ -25,13 +25,19 @@ public static IBackgroundJobsBuilder AddJob( TimeSpan? timeout = default) { if (builder is null) + { throw new ArgumentNullException(nameof(builder)); + } if (name is null) + { throw new ArgumentNullException(nameof(name)); + } if (job is null) + { throw new ArgumentNullException(nameof(job)); + } var instance = new DelegateCronJob(_ => { @@ -60,13 +66,19 @@ public static IBackgroundJobsBuilder AddJob( TimeSpan? timeout = default) { if (builder is null) + { throw new ArgumentNullException(nameof(builder)); + } if (name is null) + { throw new ArgumentNullException(nameof(name)); + } if (job is null) + { throw new ArgumentNullException(nameof(job)); + } var instance = new DelegateRecurringJob(_ => { @@ -74,7 +86,50 @@ public static IBackgroundJobsBuilder AddJob( return Task.CompletedTask; }, interval); - return builder.Add(new BackgroundJobRegistration(instance, name, timeout)); + return builder.Add(new BackgroundJobRegistration(instance, name, timeout, isRecurringJob: true)); + } + + /// + /// Adds a delegate-based background job to the builder with a specified recurring interval and initial delay. + /// + /// The builder to add the job to. + /// The name of the job. + /// The delegate to be executed as the job. + /// The interval between recurring executions of the job. + /// The initial delay before the first execution of the job. Set to to execute the job on startup. + /// The timeout for the job execution, or null to use the default timeout. + /// The updated builder. + /// Thrown if builder, name, or job is null. + public static IBackgroundJobsBuilder AddJob( + this IBackgroundJobsBuilder builder, + string name, + Action job, + TimeSpan interval, + TimeSpan initialDelay, + TimeSpan? timeout = default) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name is null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (job is null) + { + throw new ArgumentNullException(nameof(job)); + } + + var instance = new DelegateRecurringJobWithInitialDelay(_ => + { + job(); + return Task.CompletedTask; + }, interval, initialDelay); + + return builder.Add(new BackgroundJobRegistration(instance, name, timeout, isRecurringJob: true)); } /// @@ -95,13 +150,19 @@ public static IBackgroundJobsBuilder AddJob( TimeSpan? timeout = default) { if (builder is null) + { throw new ArgumentNullException(nameof(builder)); + } if (name is null) + { throw new ArgumentNullException(nameof(name)); + } if (job is null) + { throw new ArgumentNullException(nameof(job)); + } var instance = new DelegateOneTimeJob(_ => { @@ -130,13 +191,19 @@ public static IBackgroundJobsBuilder AddAsyncJob( TimeSpan? timeout = default) { if (builder is null) + { throw new ArgumentNullException(nameof(builder)); + } if (name is null) + { throw new ArgumentNullException(nameof(name)); + } if (job is null) + { throw new ArgumentNullException(nameof(job)); + } var instance = new DelegateCronJob(job, cronExpression); return builder.Add(new BackgroundJobRegistration(instance, name, timeout)); @@ -160,16 +227,60 @@ public static IBackgroundJobsBuilder AddAsyncJob( TimeSpan? timeout = default) { if (builder is null) + { throw new ArgumentNullException(nameof(builder)); + } if (name is null) + { throw new ArgumentNullException(nameof(name)); + } if (job is null) + { throw new ArgumentNullException(nameof(job)); + } var instance = new DelegateRecurringJob(job, interval); - return builder.Add(new BackgroundJobRegistration(instance, name, timeout)); + return builder.Add(new BackgroundJobRegistration(instance, name, timeout, isRecurringJob: true)); + } + + /// + /// Adds an asynchronous delegate-based background job to the builder with a specified recurring interval and initial delay. + /// + /// The builder to add the job to. + /// The name of the job. + /// The delegate to be executed as the job. + /// The interval between recurring executions of the job. + /// The initial delay before the first execution of the job. Set to to execute the job on startup. + /// The timeout for the job execution, or null to use the default timeout. + /// The updated builder. + /// Thrown if builder, name, or job is null. + public static IBackgroundJobsBuilder AddAsyncJob( + this IBackgroundJobsBuilder builder, + string name, + Func job, + TimeSpan interval, + TimeSpan initialDelay, + TimeSpan? timeout = default) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name is null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (job is null) + { + throw new ArgumentNullException(nameof(job)); + } + + var instance = new DelegateRecurringJobWithInitialDelay(job, interval, initialDelay); + return builder.Add(new BackgroundJobRegistration(instance, name, timeout, isRecurringJob: true)); } /// @@ -190,13 +301,19 @@ public static IBackgroundJobsBuilder AddAsyncJob( TimeSpan? timeout = default) { if (builder is null) + { throw new ArgumentNullException(nameof(builder)); + } if (name is null) + { throw new ArgumentNullException(nameof(name)); + } if (job is null) + { throw new ArgumentNullException(nameof(job)); + } var instance = new DelegateOneTimeJob(job, scheduledTimeUtc); return builder.Add(new BackgroundJobRegistration(instance, name, timeout)); diff --git a/src/Pilgaard.BackgroundJobs/Registration/BackgroundJobsBuilderExtensions.cs b/src/Pilgaard.BackgroundJobs/Registration/BackgroundJobsBuilderExtensions.cs index dc7f1d2..3c1e059 100644 --- a/src/Pilgaard.BackgroundJobs/Registration/BackgroundJobsBuilderExtensions.cs +++ b/src/Pilgaard.BackgroundJobs/Registration/BackgroundJobsBuilderExtensions.cs @@ -21,11 +21,11 @@ public static IBackgroundJobsBuilder AddJob( TimeSpan? timeout = default) where TJob : class, IBackgroundJob { if (builder is null) + { throw new ArgumentNullException(nameof(builder)); + } - - - return builder.Add(new BackgroundJobRegistration(GetServiceOrCreateInstance, name ?? typeof(TJob).Name, timeout)); + return builder.Add(new BackgroundJobRegistration(GetServiceOrCreateInstance, name ?? typeof(TJob).Name, timeout, typeof(TJob).ImplementsRecurringJob())); static TJob GetServiceOrCreateInstance(IServiceProvider serviceProvider) => ActivatorUtilities.GetServiceOrCreateInstance(serviceProvider); diff --git a/src/Pilgaard.BackgroundJobs/Registration/TypeExtensions.cs b/src/Pilgaard.BackgroundJobs/Registration/TypeExtensions.cs new file mode 100644 index 0000000..d4ffadc --- /dev/null +++ b/src/Pilgaard.BackgroundJobs/Registration/TypeExtensions.cs @@ -0,0 +1,6 @@ +namespace Pilgaard.BackgroundJobs; + +internal static class TypeExtensions +{ + internal static bool ImplementsRecurringJob(this Type jobType) => jobType.GetInterfaces().Any(@interface => @interface == typeof(IRecurringJob)); +} diff --git a/src/Pilgaard.BackgroundJobs/Validation/RegistrationValidator.cs b/src/Pilgaard.BackgroundJobs/Validation/RegistrationValidator.cs index 0a88adb..1817d65 100644 --- a/src/Pilgaard.BackgroundJobs/Validation/RegistrationValidator.cs +++ b/src/Pilgaard.BackgroundJobs/Validation/RegistrationValidator.cs @@ -29,6 +29,8 @@ public void Validate(ICollection registrations) } if (builder is not null) + { throw new ArgumentException(builder.ToString(0, builder.Length - 2), nameof(registrations)); + } } } diff --git a/tests/Pilgaard.BackgroundJobs.Tests/BackgroundJobSchedulerTests.cs b/tests/Pilgaard.BackgroundJobs.Tests/BackgroundJobSchedulerTests.cs index 37c9136..a9aa27c 100644 --- a/tests/Pilgaard.BackgroundJobs.Tests/BackgroundJobSchedulerTests.cs +++ b/tests/Pilgaard.BackgroundJobs.Tests/BackgroundJobSchedulerTests.cs @@ -101,9 +101,14 @@ public async Task be_able_to_return_all_types_of_background_job() var backgroundJobs = sut .GetOrderedBackgroundJobOccurrences(TimeSpan.FromMinutes(2)) .ToArray(); + var recurringJobs = sut.GetRecurringJobs(); // Assert - var distinctBackgroundJobs = new HashSet(StringComparer.OrdinalIgnoreCase); + var distinctBackgroundJobs = new HashSet(StringComparer.OrdinalIgnoreCase) + { + // Recurring jobs are returned separately, add it to the list manually + recurringJobs.FirstOrDefault()!.Name + }; foreach (var (occurrence, backgroundJobRegistration) in backgroundJobs) { string backgroundJobType = backgroundJobRegistration.Factory(serviceProvider).GetType().Name; @@ -128,4 +133,26 @@ public async Task throw_an_argument_exception_when_background_jobs_have_the_same // Act && Assert Assert.Throws(() => serviceProvider.GetRequiredService()); } + + [Fact] + public async Task not_return_more_cronjob_occurrences_than_there_are() + { + // Arrange + _services + .AddSingleton() + .AddBackgroundJobs() + // Run every 5 seconds + .AddJob("CronJob", () => { }, CronExpression.Parse("*/5 * * * * *", CronFormat.IncludeSeconds)); + + await using var serviceProvider = _services.BuildServiceProvider(); + var sut = serviceProvider.GetRequiredService(); + + // Act + var backgroundJobs = sut + .GetOrderedBackgroundJobOccurrences(TimeSpan.FromMinutes(1)) + .ToArray(); + + // Assert + backgroundJobs.Length.Should().Be(60 / 5); + } } diff --git a/tests/Pilgaard.BackgroundJobs.Tests/BackgroundJobServiceTests.cs b/tests/Pilgaard.BackgroundJobs.Tests/BackgroundJobServiceTests.cs index 699cb97..d06c6e2 100644 --- a/tests/Pilgaard.BackgroundJobs.Tests/BackgroundJobServiceTests.cs +++ b/tests/Pilgaard.BackgroundJobs.Tests/BackgroundJobServiceTests.cs @@ -21,12 +21,15 @@ public async Task run_background_jobs() // Arrange string? output1 = null; string? output2 = null; + string? output3 = null; + string? output4 = null; _services - .AddSingleton() .AddBackgroundJobs() .AddJob("FastRecurringJob", () => output1 = "not empty", TimeSpan.FromSeconds(1)) - .AddJob("FasterRecurringJob", () => output2 = "not empty", CronExpression.Parse("* * * * * *", CronFormat.IncludeSeconds)); + .AddJob("FastRecurringJobWithInitialDelay", () => output2 = "not empty", TimeSpan.FromSeconds(1), TimeSpan.Zero) + .AddJob("FastCronJob", () => output3 = "not empty", CronExpression.Parse("* * * * * *", CronFormat.IncludeSeconds)) + .AddJob("FastOneTimeJob", () => output4 = "not empty", DateTime.UtcNow.AddSeconds(1)); await using var serviceProvider = _services.BuildServiceProvider(); @@ -47,5 +50,7 @@ public async Task run_background_jobs() // Assert output1.Should().NotBeNull(); output2.Should().NotBeNull(); + output3.Should().NotBeNull(); + output4.Should().NotBeNull(); } } diff --git a/tests/Pilgaard.BackgroundJobs.Tests/DependencyInjection/DelegateDependencyInjectionTests.cs b/tests/Pilgaard.BackgroundJobs.Tests/DependencyInjection/DelegateDependencyInjectionTests.cs index 4507c5d..a8663ed 100644 --- a/tests/Pilgaard.BackgroundJobs.Tests/DependencyInjection/DelegateDependencyInjectionTests.cs +++ b/tests/Pilgaard.BackgroundJobs.Tests/DependencyInjection/DelegateDependencyInjectionTests.cs @@ -28,6 +28,9 @@ public async Task add_cronjob_when_properly_configured() backgroundJobServiceOptions.Value.Registrations.FirstOrDefault() .Should().NotBeNull(); + backgroundJobServiceOptions.Value.Registrations + .First().IsRecurringJob.Should().BeFalse(); + backgroundJobServiceOptions.Value.Registrations .First().Factory(serviceProvider) .Should().BeOfType() @@ -56,6 +59,9 @@ public async Task add_recurringjob_when_properly_configured() backgroundJobServiceOptions.Value.Registrations.FirstOrDefault() .Should().NotBeNull(); + backgroundJobServiceOptions.Value.Registrations + .First().IsRecurringJob.Should().BeTrue(); + backgroundJobServiceOptions.Value.Registrations .First().Factory(serviceProvider) .Should().BeOfType() @@ -84,6 +90,9 @@ public async Task add_onetimejob_when_properly_configured() backgroundJobServiceOptions.Value.Registrations.FirstOrDefault() .Should().NotBeNull(); + backgroundJobServiceOptions.Value.Registrations + .First().IsRecurringJob.Should().BeFalse(); + backgroundJobServiceOptions.Value.Registrations .First().Factory(serviceProvider) .Should().BeOfType() @@ -112,6 +121,9 @@ public async Task add_async_cronjob_when_properly_configured() backgroundJobServiceOptions.Value.Registrations.FirstOrDefault() .Should().NotBeNull(); + backgroundJobServiceOptions.Value.Registrations + .First().IsRecurringJob.Should().BeFalse(); + backgroundJobServiceOptions.Value.Registrations .First().Factory(serviceProvider) .Should().BeOfType() @@ -140,6 +152,9 @@ public async Task add_async_recurringjob_when_properly_configured() backgroundJobServiceOptions.Value.Registrations.FirstOrDefault() .Should().NotBeNull(); + backgroundJobServiceOptions.Value.Registrations + .First().IsRecurringJob.Should().BeTrue(); + backgroundJobServiceOptions.Value.Registrations .First().Factory(serviceProvider) .Should().BeOfType() @@ -168,6 +183,9 @@ public async Task add_async_onetimejob_when_properly_configured() backgroundJobServiceOptions.Value.Registrations.FirstOrDefault() .Should().NotBeNull(); + backgroundJobServiceOptions.Value.Registrations + .First().IsRecurringJob.Should().BeFalse(); + backgroundJobServiceOptions.Value.Registrations .First().Factory(serviceProvider) .Should().BeOfType() diff --git a/tests/Pilgaard.BackgroundJobs.Tests/DependencyInjection/DependencyInjectionTests.cs b/tests/Pilgaard.BackgroundJobs.Tests/DependencyInjection/DependencyInjectionTests.cs index 7c800e1..fd95809 100644 --- a/tests/Pilgaard.BackgroundJobs.Tests/DependencyInjection/DependencyInjectionTests.cs +++ b/tests/Pilgaard.BackgroundJobs.Tests/DependencyInjection/DependencyInjectionTests.cs @@ -22,8 +22,11 @@ public async Task add_cronjob_when_properly_configured() var backgroundJobServiceOptions = serviceProvider.GetRequiredService>(); - backgroundJobServiceOptions.Value.Registrations.FirstOrDefault() - .Should().NotBeNull(); + backgroundJobServiceOptions.Value.Registrations + .FirstOrDefault().Should().NotBeNull(); + + backgroundJobServiceOptions.Value.Registrations + .First().IsRecurringJob.Should().BeFalse(); backgroundJobServiceOptions.Value.Registrations .First().Factory(serviceProvider) @@ -50,6 +53,9 @@ public async Task add_recurringjob_when_properly_configured() backgroundJobServiceOptions.Value.Registrations.FirstOrDefault() .Should().NotBeNull(); + backgroundJobServiceOptions.Value.Registrations + .First().IsRecurringJob.Should().BeTrue(); + backgroundJobServiceOptions.Value.Registrations .First().Factory(serviceProvider) .Should().BeOfType() @@ -75,6 +81,9 @@ public async Task add_onetimejob_when_properly_configured() backgroundJobServiceOptions.Value.Registrations.FirstOrDefault() .Should().NotBeNull(); + backgroundJobServiceOptions.Value.Registrations + .First().IsRecurringJob.Should().BeFalse(); + backgroundJobServiceOptions.Value.Registrations .First().Factory(serviceProvider) .Should().BeOfType() @@ -113,5 +122,5 @@ public Task RunJobAsync(CancellationToken cancellationToken = default) return Task.CompletedTask; } - public DateTime ScheduledTimeUtc => new(year: 2023, month: 12, day: 31, hour: 23, minute: 59, second: 59); + public DateTime ScheduledTimeUtc => new(year: 2025, month: 12, day: 31, hour: 23, minute: 59, second: 59); } diff --git a/tests/Pilgaard.BackgroundJobs.Tests/Pilgaard.BackgroundJobs.approved.txt b/tests/Pilgaard.BackgroundJobs.Tests/Pilgaard.BackgroundJobs.approved.txt index 1440890..eac4b51 100644 --- a/tests/Pilgaard.BackgroundJobs.Tests/Pilgaard.BackgroundJobs.approved.txt +++ b/tests/Pilgaard.BackgroundJobs.Tests/Pilgaard.BackgroundJobs.approved.txt @@ -7,8 +7,8 @@ } public sealed class BackgroundJobRegistration { - public BackgroundJobRegistration(Pilgaard.BackgroundJobs.IBackgroundJob instance, string name, System.TimeSpan? timeout = default) { } - public BackgroundJobRegistration(System.Func factory, string name, System.TimeSpan? timeout = default) { } + public BackgroundJobRegistration(Pilgaard.BackgroundJobs.IBackgroundJob instance, string name, System.TimeSpan? timeout = default, bool isRecurringJob = false) { } + public BackgroundJobRegistration(System.Func factory, string name, System.TimeSpan? timeout = default, bool isRecurringJob = false) { } public System.Func Factory { get; } public string Name { get; } public System.TimeSpan Timeout { get; } @@ -23,9 +23,11 @@ public static Pilgaard.BackgroundJobs.IBackgroundJobsBuilder AddAsyncJob(this Pilgaard.BackgroundJobs.IBackgroundJobsBuilder builder, string name, System.Func job, Cronos.CronExpression cronExpression, System.TimeSpan? timeout = default) { } public static Pilgaard.BackgroundJobs.IBackgroundJobsBuilder AddAsyncJob(this Pilgaard.BackgroundJobs.IBackgroundJobsBuilder builder, string name, System.Func job, System.DateTime scheduledTimeUtc, System.TimeSpan? timeout = default) { } public static Pilgaard.BackgroundJobs.IBackgroundJobsBuilder AddAsyncJob(this Pilgaard.BackgroundJobs.IBackgroundJobsBuilder builder, string name, System.Func job, System.TimeSpan interval, System.TimeSpan? timeout = default) { } + public static Pilgaard.BackgroundJobs.IBackgroundJobsBuilder AddAsyncJob(this Pilgaard.BackgroundJobs.IBackgroundJobsBuilder builder, string name, System.Func job, System.TimeSpan interval, System.TimeSpan initialDelay, System.TimeSpan? timeout = default) { } public static Pilgaard.BackgroundJobs.IBackgroundJobsBuilder AddJob(this Pilgaard.BackgroundJobs.IBackgroundJobsBuilder builder, string name, System.Action job, Cronos.CronExpression cronExpression, System.TimeSpan? timeout = default) { } public static Pilgaard.BackgroundJobs.IBackgroundJobsBuilder AddJob(this Pilgaard.BackgroundJobs.IBackgroundJobsBuilder builder, string name, System.Action job, System.DateTime scheduledTimeUtc, System.TimeSpan? timeout = default) { } public static Pilgaard.BackgroundJobs.IBackgroundJobsBuilder AddJob(this Pilgaard.BackgroundJobs.IBackgroundJobsBuilder builder, string name, System.Action job, System.TimeSpan interval, System.TimeSpan? timeout = default) { } + public static Pilgaard.BackgroundJobs.IBackgroundJobsBuilder AddJob(this Pilgaard.BackgroundJobs.IBackgroundJobsBuilder builder, string name, System.Action job, System.TimeSpan interval, System.TimeSpan initialDelay, System.TimeSpan? timeout = default) { } } public static class BackgroundJobsBuilderExtensions { @@ -41,7 +43,7 @@ { System.Threading.Tasks.Task RunJobAsync(System.Threading.CancellationToken cancellationToken = default); } - public interface IBackgroundJobService + public interface IBackgroundJobService : System.IDisposable { System.Threading.Tasks.Task RunJobsAsync(System.Threading.CancellationToken cancellationToken = default); } @@ -62,6 +64,10 @@ { System.TimeSpan Interval { get; } } + public interface IRecurringJobWithInitialDelay : Pilgaard.BackgroundJobs.IBackgroundJob, Pilgaard.BackgroundJobs.IRecurringJob + { + System.TimeSpan InitialDelay { get; } + } public static class OneTimeJobExtensions { public static System.DateTime? GetNextOccurrence(this Pilgaard.BackgroundJobs.IOneTimeJob oneTimeJob) { }