diff --git a/src/Pilgaard.BackgroundJobs/BackgroundJobScheduler.cs b/src/Pilgaard.BackgroundJobs/BackgroundJobScheduler.cs index 2144daa..842bbcf 100644 --- a/src/Pilgaard.BackgroundJobs/BackgroundJobScheduler.cs +++ b/src/Pilgaard.BackgroundJobs/BackgroundJobScheduler.cs @@ -10,118 +10,117 @@ namespace Pilgaard.BackgroundJobs; /// internal sealed class BackgroundJobScheduler : IBackgroundJobScheduler { - private readonly IServiceScopeFactory _scopeFactory; - private readonly IOptions _options; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class - /// - /// The factory used when constructing background jobs. - /// The options used for accessing background job registrations. - /// The validator used for validating the background job registrations. - /// The logger. - public BackgroundJobScheduler(IServiceScopeFactory scopeFactory, - IOptions options, - IRegistrationValidator registrationsValidator, - ILogger logger) - { - _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(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); - } - - public async IAsyncEnumerable GetBackgroundJobsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) - { - var interval = TimeSpan.FromSeconds(30); - - var backgroundJobOccurrences = GetOrderedBackgroundJobOccurrences(interval); - - // Check if there's anything to enumerate - // If false, sleep and return - if (!backgroundJobOccurrences.Any()) - { - var intervalMinus5Seconds = interval.Subtract(TimeSpan.FromSeconds(5)); - - _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); - - // When we yield break, GetBackgroundJobsAsync will be called again immediately - yield break; - } - - foreach (var (occurrence, backgroundJob) in backgroundJobOccurrences) - { - var timeUntilNextOccurrence = occurrence.Subtract(DateTime.UtcNow); - - _logger.LogDebug("Background job {jobName} will execute in {timeUntilNextOccurrence}", - backgroundJob.Name, timeUntilNextOccurrence); - - if (timeUntilNextOccurrence > TimeSpan.Zero) - { - await Task.Delay(timeUntilNextOccurrence, cancellationToken); - } - - yield return backgroundJob; - } - } - - - public IEnumerable GetRecurringJobs() => _options.Value.Registrations.Where(registration => registration.IsRecurringJob); - - /// - /// Gets an ordered enumerable of background job occurrences within the specified . - /// - /// The interval to get occurrences for. - /// - internal IEnumerable GetOrderedBackgroundJobOccurrences(TimeSpan fetchInterval) - { - var toUtc = DateTime.UtcNow.Add(fetchInterval); - - using var scope = _scopeFactory.CreateScope(); - - var backgroundJobOccurrences = new List(); - - foreach (var registration in _options.Value.Registrations.Where(registration => !registration.IsRecurringJob)) - { - var backgroundJob = registration.Factory(scope.ServiceProvider); - - backgroundJobOccurrences.AddRange( - backgroundJob - .GetOccurrences(toUtc) - .OrderBy(dateTime => dateTime) - .Select(occurrence => - new BackgroundJobOccurrence(occurrence, registration))); - } - - var orderedBackgroundJobOccurrences = backgroundJobOccurrences - .OrderBy(backgroundJobOccurrence => backgroundJobOccurrence.Occurrence); - - foreach (var backgroundJobOccurrence in orderedBackgroundJobOccurrences) - { - yield return backgroundJobOccurrence; - } - } + private readonly IServiceScopeFactory _scopeFactory; + private readonly IOptions _options; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class + /// + /// The factory used when constructing background jobs. + /// The options used for accessing background job registrations. + /// The validator used for validating the background job registrations. + /// The logger. + public BackgroundJobScheduler(IServiceScopeFactory scopeFactory, + IOptions options, + IRegistrationValidator registrationsValidator, + ILogger logger) + { + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(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); + } + + public async IAsyncEnumerable GetBackgroundJobsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var interval = TimeSpan.FromSeconds(30); + + var backgroundJobOccurrences = GetOrderedBackgroundJobOccurrences(interval); + + // Check if there's anything to enumerate + // If false, sleep and return + if (backgroundJobOccurrences.Count <= 0) + { + var intervalMinus5Seconds = interval.Subtract(TimeSpan.FromSeconds(5)); + + _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); + + // When we yield break, GetBackgroundJobsAsync will be called again immediately + yield break; + } + + foreach (var (occurrence, backgroundJob) in backgroundJobOccurrences) + { + var timeUntilNextOccurrence = occurrence.Subtract(DateTime.UtcNow); + + _logger.LogDebug("Background job {jobName} will execute in {timeUntilNextOccurrence}", + backgroundJob.Name, timeUntilNextOccurrence); + + if (timeUntilNextOccurrence > TimeSpan.Zero) + { + await Task.Delay(timeUntilNextOccurrence, cancellationToken); + } + + yield return backgroundJob; + } + } + + + public IEnumerable GetRecurringJobs() => _options.Value.Registrations.Where(registration => registration.IsRecurringJob); + + /// + /// Gets an ordered enumerable of background job occurrences within the specified . + /// + /// The interval to get occurrences for. + /// + internal List GetOrderedBackgroundJobOccurrences(TimeSpan fetchInterval) + { + var toUtc = DateTime.UtcNow.Add(fetchInterval); + + using var scope = _scopeFactory.CreateScope(); + + var backgroundJobOccurrences = new List(); + + foreach (var registration in _options.Value.Registrations.Where(registration => !registration.IsRecurringJob)) + { + var backgroundJob = registration.Factory(scope.ServiceProvider); + + backgroundJobOccurrences.AddRange( + backgroundJob + .GetOccurrences(toUtc) + // deduplicate the occurrences + .Distinct() + .OrderBy(dateTime => dateTime) + .Select(occurrence => + new BackgroundJobOccurrence(occurrence, registration))); + } + + return backgroundJobOccurrences + .OrderBy(x => x.Occurrence) + .ToList(); + } } /// -/// A background job registration and one of it's occurrences. +/// A background job registration and one of its occurrences. /// /// The time the background job should run. /// The background job registration. internal readonly record struct BackgroundJobOccurrence( - DateTime Occurrence, - BackgroundJobRegistration BackgroundJobRegistration) + DateTime Occurrence, + BackgroundJobRegistration BackgroundJobRegistration) { - public DateTime Occurrence { get; } = Occurrence; - public BackgroundJobRegistration BackgroundJobRegistration { get; } = BackgroundJobRegistration; + public DateTime Occurrence { get; } = Occurrence; + public BackgroundJobRegistration BackgroundJobRegistration { get; } = BackgroundJobRegistration; } diff --git a/tests/Pilgaard.BackgroundJobs.Tests/BackgroundJobSchedulerTests.cs b/tests/Pilgaard.BackgroundJobs.Tests/BackgroundJobSchedulerTests.cs index f59975e..e999e38 100644 --- a/tests/Pilgaard.BackgroundJobs.Tests/BackgroundJobSchedulerTests.cs +++ b/tests/Pilgaard.BackgroundJobs.Tests/BackgroundJobSchedulerTests.cs @@ -6,153 +6,176 @@ namespace Pilgaard.BackgroundJobs.Tests; public class backgroundjobscheduler_should { - private readonly ITestOutputHelper _testOutput; - private readonly IServiceCollection _services; - - public backgroundjobscheduler_should(ITestOutputHelper testOutput) - { - _testOutput = testOutput; - _services = new ServiceCollection().AddLogging(); - } - - [Fact] - public async Task return_background_jobs_in_order_of_occurrence() - { - // Arrange - _services - .AddSingleton() - .AddBackgroundJobs() - .AddJob("FastRecurringJob", () => { }, TimeSpan.FromSeconds(10)) - .AddJob("FasterRecurringJob", () => { }, TimeSpan.FromSeconds(3)); - - await using var serviceProvider = _services.BuildServiceProvider(); - - var sut = serviceProvider.GetRequiredService(); - - // Act - var backgroundJobs = sut - .GetOrderedBackgroundJobOccurrences(TimeSpan.FromSeconds(30)) - .ToArray(); - - // Assert - var lastOccurrence = DateTime.MinValue; - foreach (var (occurrence, backgroundJob) in backgroundJobs) - { - _testOutput.WriteLine($"[{backgroundJob}]: {occurrence}"); - occurrence.Should().BeAfter(lastOccurrence); - lastOccurrence = occurrence; - } - } - - [Fact(Timeout = 5500)] - public async Task return_background_jobs_when_they_should_be_run() - { - // Arrange - _services - .AddSingleton() - .AddBackgroundJobs() - .AddJob("RecurringJob", () => { }, TimeSpan.FromSeconds(1)); - - await using var serviceProvider = _services.BuildServiceProvider(); - - var sut = serviceProvider.GetRequiredService(); - - var startTime = DateTime.UtcNow; - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - - // Act - var backgroundJobs = sut.GetBackgroundJobsAsync(cts.Token); - - // Assert - ushort index = 1; - - try - { - await foreach (var backgroundJob in backgroundJobs) - { - var now = DateTime.UtcNow; - now.Second.Should().Be(startTime.AddSeconds(index++).Second); - _testOutput.WriteLine($"[{backgroundJob}]: {now}"); - } - } - catch - { - // This test throws to stop - } - } - - [Fact] - public async Task be_able_to_return_all_types_of_background_job() - { - // Arrange - _services - .AddSingleton() - .AddBackgroundJobs() - .AddJob("RecurringJob", () => { }, TimeSpan.FromSeconds(10)) - .AddJob("CronJob", () => { }, CronExpression.Parse("0,30 * * * * *", CronFormat.IncludeSeconds)) - .AddJob("OneTimeJob", () => { }, DateTime.UtcNow.AddSeconds(17)); - - await using var serviceProvider = _services.BuildServiceProvider(); - - var sut = serviceProvider.GetRequiredService(); - - // Act - var backgroundJobs = sut - .GetOrderedBackgroundJobOccurrences(TimeSpan.FromMinutes(2)) - .ToArray(); - var recurringJobs = sut.GetRecurringJobs(); - - // Assert - var distinctBackgroundJobs = new HashSet(StringComparer.OrdinalIgnoreCase) - { + private readonly ITestOutputHelper _testOutput; + private readonly IServiceCollection _services; + + public backgroundjobscheduler_should(ITestOutputHelper testOutput) + { + _testOutput = testOutput; + _services = new ServiceCollection().AddLogging(); + } + + [Fact] + public async Task return_background_jobs_in_order_of_occurrence() + { + // Arrange + _services + .AddSingleton() + .AddBackgroundJobs() + .AddJob("FastRecurringJob", () => { }, TimeSpan.FromSeconds(10)) + .AddJob("FasterRecurringJob", () => { }, TimeSpan.FromSeconds(3)); + + await using var serviceProvider = _services.BuildServiceProvider(); + + var sut = serviceProvider.GetRequiredService(); + + // Act + var backgroundJobs = sut + .GetOrderedBackgroundJobOccurrences(TimeSpan.FromSeconds(30)) + .ToArray(); + + // Assert + var lastOccurrence = DateTime.MinValue; + foreach (var (occurrence, backgroundJob) in backgroundJobs) + { + _testOutput.WriteLine($"[{backgroundJob}]: {occurrence}"); + occurrence.Should().BeAfter(lastOccurrence); + lastOccurrence = occurrence; + } + } + + [Fact(Timeout = 5500)] + public async Task return_background_jobs_when_they_should_be_run() + { + // Arrange + _services + .AddSingleton() + .AddBackgroundJobs() + .AddJob("RecurringJob", () => { }, TimeSpan.FromSeconds(1)); + + await using var serviceProvider = _services.BuildServiceProvider(); + + var sut = serviceProvider.GetRequiredService(); + + var startTime = DateTime.UtcNow; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + // Act + var backgroundJobs = sut.GetBackgroundJobsAsync(cts.Token); + + // Assert + ushort index = 1; + + try + { + await foreach (var backgroundJob in backgroundJobs) + { + var now = DateTime.UtcNow; + now.Second.Should().Be(startTime.AddSeconds(index++).Second); + _testOutput.WriteLine($"[{backgroundJob}]: {now}"); + } + } + catch + { + // This test throws to stop + } + } + + [Fact] + public async Task be_able_to_return_all_types_of_background_job() + { + // Arrange + _services + .AddSingleton() + .AddBackgroundJobs() + .AddJob("RecurringJob", () => { }, TimeSpan.FromSeconds(10)) + .AddJob("CronJob", () => { }, CronExpression.Parse("0,30 * * * * *", CronFormat.IncludeSeconds)) + .AddJob("OneTimeJob", () => { }, DateTime.UtcNow.AddSeconds(17)); + + await using var serviceProvider = _services.BuildServiceProvider(); + + var sut = serviceProvider.GetRequiredService(); + + // Act + var backgroundJobs = sut + .GetOrderedBackgroundJobOccurrences(TimeSpan.FromMinutes(2)) + .ToArray(); + var recurringJobs = sut.GetRecurringJobs(); + + // Assert + 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; - distinctBackgroundJobs.Add(backgroundJobType); - _testOutput.WriteLine($"[{backgroundJobType}]: {occurrence}"); - } - - distinctBackgroundJobs.Should().HaveCount(3); - } - - [Fact] - public async Task throw_an_argument_exception_when_background_jobs_have_the_same_name() - { - // Arrange - _services - .AddBackgroundJobs() - .AddJob("DuplicateJob", () => { }, TimeSpan.FromSeconds(10)) - .AddJob("DuplicateJob", () => { }, TimeSpan.FromSeconds(10)); - - await using var serviceProvider = _services.BuildServiceProvider(); - - // 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); - } + }; + foreach (var (occurrence, backgroundJobRegistration) in backgroundJobs) + { + string backgroundJobType = backgroundJobRegistration.Factory(serviceProvider).GetType().Name; + distinctBackgroundJobs.Add(backgroundJobType); + _testOutput.WriteLine($"[{backgroundJobType}]: {occurrence}"); + } + + distinctBackgroundJobs.Should().HaveCount(3); + } + + [Fact] + public async Task throw_an_argument_exception_when_background_jobs_have_the_same_name() + { + // Arrange + _services + .AddBackgroundJobs() + .AddJob("DuplicateJob", () => { }, TimeSpan.FromSeconds(10)) + .AddJob("DuplicateJob", () => { }, TimeSpan.FromSeconds(10)); + + await using var serviceProvider = _services.BuildServiceProvider(); + + // 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); + } + + [Fact] + public async Task not_return_duplicate_cronjobs() + { + // Arrange + _services + .AddSingleton() + .AddBackgroundJobs() + // Run every 5 seconds + .AddJob("CronJob", () => { }, CronExpression.Parse("*/5 * * * *")); + + await using var serviceProvider = _services.BuildServiceProvider(); + var sut = serviceProvider.GetRequiredService(); + + // Act + var duplicateOccurrences = sut.GetOrderedBackgroundJobOccurrences(TimeSpan.FromHours(1)) + .GroupBy(job => job.Occurrence) + .Where(group => group.Count() > 1) + .Select(group => group.Key); + + // Assert + duplicateOccurrences.Should().BeEmpty(); + } }