Compare commits

...
Author SHA1 Message Date
Ronald Barendse e93419c18d Add IRecurringBackgroundJobTrigger<TJob> for opt-in job triggering 2026-04-17 10:30:56 +02:00
Ronald Barendse 4182e858a8 Tidy RecurringBackgroundJobBase docs and runner error handling 2026-04-17 10:04:35 +02:00
Ronald Barendse 0631f0a749 Replace Task.Yield with semaphore timeouts in negative assertions 2026-04-09 16:19:42 +02:00
Ronald Barendse 9c3ac1fb37 Remove hosted service from dictionary before stopping to prevent triggering during shutdown 2026-04-09 16:19:41 +02:00
Ronald Barendse 7e97d46c81 Use Interlocked for _period reads/writes and implement thread-safe dispose pattern 2026-04-09 16:19:40 +02:00
Ronald Barendse 7795e974e3 Fix trigger state race, simplify ReleaseSignal, and add canceled notification test
Fix trigger state
2026-04-09 16:19:20 +02:00
Ronald Barendse 48f407b2e6 Ensure PeriodChanged event is unsubscribed again 2026-04-09 15:42:07 +02:00
Ronald Barendse ed4136804e Set up Period and Delay on mock job to satisfy constructor validation 2026-04-09 11:03:00 +02:00
Ronald Barendse 312c20fbb9 Validate period is positive and use GetOrAdd to avoid creating unused hosted services 2026-04-08 11:06:10 +02:00
Ronald Barendse 9c9518d723 Clarify TriggerExecution(TimeSpan) docs and add ChangePeriod test 2026-04-08 10:08:08 +02:00
Ronald Barendse a2e11c73d6 Configure IEventMessagesFactory mock to return real EventMessages 2026-04-08 08:57:40 +02:00
Ronald Barendse 3f1325ecf1 Avoid disposing period-change CTS while wait loop may still reference it 2026-04-08 08:55:21 +02:00
Ronald Barendse 5d1b7da959 Fix Exception_In_PerformExecuteAsync_Does_Not_Kill_Loop test 2026-04-07 23:25:17 +02:00
Ronald BarendseandGitHub d245c0299f Merge branch 'main' into v17/feature/recurringbackgroundjob-signalling 2026-04-07 22:35:09 +02:00
Ronald Barendse 2a3df70e4d Use DelayCalculator.GetDelay instead of RecurringHostedServiceBase.GetDelay 2026-04-07 22:27:03 +02:00
Ronald Barendse e984e0e56f Inject TimeProvider into RecurringHostedServiceBase for deterministic testing
Fix timeprovider
2026-04-07 22:25:13 +02:00
Ronald Barendse 524a56d87d Use semaphore signaling instead of Task.Delay in trigger tests
Use semaphore signaling instead of Task.Delay in trigger tests 2
2026-04-07 22:11:23 +02:00
Ronald Barendse 34e51e4fde Remove failed hosted service from dictionary when StartAsync throws 2026-04-07 12:53:58 +02:00
Ronald Barendse 417c7ae3af Register RecurringBackgroundJobHostedServiceRunner as resolvable singleton 2026-04-07 12:53:58 +02:00
Andy Butland 9e510e0940 Removed unneeded using. 2026-04-03 11:53:32 +02:00
Ronald Barendse 7d82afa6ad Fix API compatibility errors 2026-04-02 22:54:00 +02:00
Ronald Barendse 3012138267 Remove hosted services from dictionary on stop 2026-04-02 22:12:37 +02:00
Ronald Barendse 3607092e85 Use ConcurrentDictionary for thread-safe hosted service lookup 2026-04-02 22:09:07 +02:00
Ronald Barendse 7f290696e9 Consolidate trigger state into an immutable record for thread safety 2026-04-02 21:39:21 +02:00
Ronald Barendse 42f15d898b Combine ComputeNextDelay tests 2026-04-02 17:22:47 +02:00
Ronald Barendse 7aaa786a88 Clear _nextExecutionSkipOnOvershoot unconditionally 2026-04-02 17:21:13 +02:00
Ronald Barendse 504a56ce12 Clear trigger state when initial delay is interrupted 2026-04-02 17:21:13 +02:00
Ronald Barendse d226d8c8ff Extract shared helper for TriggerExecution tests 2026-04-02 17:21:13 +02:00
Ronald Barendse 4eca474770 Match hosted services by Type instead of type name string 2026-04-02 17:21:13 +02:00
Ronald Barendse de7e72e06c Handle cancellation (application shutdown) and publish RecurringBackgroundJobCanceledNotification 2026-04-02 17:21:12 +02:00
Ronald Barendse 4f50356588 Merge branch 'main' into v17/feature/recurringbackgroundjob-signalling
# Conflicts:
#	src/Umbraco.Infrastructure/BackgroundJobs/IRecurringBackgroundJob.cs
#	src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedService.cs
#	src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceRunner.cs
#	src/Umbraco.Infrastructure/Extensions/ServiceCollectionExtensions.cs
#	src/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBase.cs
#	tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBaseTests.cs
2026-04-02 13:29:53 +02:00
Ronald BarendseandClaude Opus 4.6 9f8b533b1e Add TriggerExecution methods to RecurringBackgroundJobHostedServiceRunner
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:08:09 +02:00
Ronald Barendse d1ec78d2a8 Add NextExecutionStrategy parameter to adjust the schedule after triggered executions 2026-04-01 23:32:59 +02:00
Ronald Barendse 8fe60d6569 Add RecurringBackgroundJobBase to contain default values and hide obsoleted method 2026-04-01 23:32:59 +02:00
Ronald Barendse a2d97f8464 Use SemaphoreSlim to properly handle exceptions, cancellation tokens and triggering immediate executions 2026-04-01 23:32:57 +02:00
Ronald Barendse 3c2f198960 Compute next delay to compensate for time drift 2026-03-07 22:33:17 +01:00
19 changed files with 1462 additions and 391 deletions
@@ -1,8 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Configuration;
@@ -14,37 +9,48 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs
public class DelayCalculator
{
/// <summary>
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optonal
/// configuration for the first run time is available.
/// Determines the delay before the first run of a recurring task, using a <see cref="TimeProvider" /> for the current time.
/// </summary>
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser"/></param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser" />.</param>
/// <param name="logger">The logger.</param>
/// <param name="timeProvider">The time provider used to determine the current time.</param>
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
/// <returns>The delay before first running the recurring task.</returns>
public static TimeSpan GetDelay(
string firstRunTime,
ICronTabParser cronTabParser,
ILogger logger,
TimeSpan defaultDelay) => GetDelay(firstRunTime, cronTabParser, logger, DateTime.Now, defaultDelay);
/// <returns>
/// The delay before first running the recurring task.
/// </returns>
public static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, TimeProvider timeProvider, TimeSpan defaultDelay)
=> GetDelay(firstRunTime, cronTabParser, logger, timeProvider.GetLocalNow().DateTime, defaultDelay);
/// <summary>
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optonal
/// configuration for the first run time is available.
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optional configuration for the first run time is available.
/// </summary>
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser"/></param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser" />.</param>
/// <param name="logger">The logger.</param>
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
/// <returns>
/// The delay before first running the recurring task.
/// </returns>
[Obsolete("Use the overload accepting TimeProvider. Scheduled for removal in Umbraco 19.")]
public static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, TimeSpan defaultDelay)
=> GetDelay(firstRunTime, cronTabParser, logger, DateTime.Now, defaultDelay);
/// <summary>
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optional configuration for the first run time is available.
/// </summary>
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser" />.</param>
/// <param name="logger">The logger.</param>
/// <param name="now">The current datetime.</param>
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
/// <returns>The delay before first running the recurring task.</returns>
/// <remarks>Internal to expose for unit tests.</remarks>
internal static TimeSpan GetDelay(
string firstRunTime,
ICronTabParser cronTabParser,
ILogger logger,
DateTime now,
TimeSpan defaultDelay)
/// <returns>
/// The delay before first running the recurring task.
/// </returns>
/// <remarks>
/// Internal to expose for unit tests.
/// </remarks>
internal static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, DateTime now, TimeSpan defaultDelay)
{
// If first run time not set, start with just small delay after application start.
if (string.IsNullOrEmpty(firstRunTime))
@@ -56,12 +62,14 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs
if (!cronTabParser.IsValidCronTab(firstRunTime))
{
logger.LogWarning("Could not parse {FirstRunTime} as a crontab expression. Defaulting to default delay for hosted service start.", firstRunTime);
return defaultDelay;
}
// Otherwise start at scheduled time according to cron expression, unless within the default delay period.
DateTime firstRunOccurance = cronTabParser.GetNextOccurrence(firstRunTime, now);
TimeSpan delay = firstRunOccurance - now;
DateTime firstRunOccurrence = cronTabParser.GetNextOccurrence(firstRunTime, now);
TimeSpan delay = firstRunOccurrence - now;
return delay < defaultDelay
? defaultDelay
: delay;
@@ -1,38 +1,71 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// A recurring background job
/// A recurring background job.
/// </summary>
public interface IRecurringBackgroundJob
{
static readonly TimeSpan DefaultDelay = System.TimeSpan.FromMinutes(3);
static readonly ServerRole[] DefaultServerRoles = new[] { ServerRole.Single, ServerRole.SchedulingPublisher };
/// <summary>
/// The default delay to use for recurring tasks for the first run after application start-up if no alternative is configured.
/// </summary>
[Obsolete("Use RecurringBackgroundJobBase.DefaultDelay instead. Scheduled for removal in Umbraco 19.")]
static readonly TimeSpan DefaultDelay = RecurringBackgroundJobBase.DefaultDelay;
/// <summary>
/// The default server roles that recurring background jobs run on.
/// </summary>
[Obsolete("Use RecurringBackgroundJobBase.DefaultServerRoles instead. Scheduled for removal in Umbraco 19.")]
static readonly ServerRole[] DefaultServerRoles = RecurringBackgroundJobBase.DefaultServerRoles;
/// <summary>
/// Timespan representing how often the task should recur.
/// </summary>
/// <value>
/// The period.
/// </value>
TimeSpan Period { get; }
/// <summary>
/// Timespan representing the initial delay after application start-up before the first run of the task
/// occurs.
/// Timespan representing the initial delay after application start-up before the first run of the task occurs.
/// </summary>
TimeSpan Delay { get => DefaultDelay; }
/// <value>
/// The delay.
/// </value>
TimeSpan Delay => RecurringBackgroundJobBase.DefaultDelay; // TODO (V19): Remove the default implementation
/// <summary>
/// Gets the server roles for which this recurring background job is intended.
/// Gets the server roles the task executes on.
/// </summary>
ServerRole[] ServerRoles { get => DefaultServerRoles; }
/// <value>
/// The server roles.
/// </value>
ServerRole[] ServerRoles => RecurringBackgroundJobBase.DefaultServerRoles; // TODO (V19): Remove the default implementation
/// <summary>
/// This event should be raised when the <see cref="Period" /> property changes to notify the background job manager to update the schedule for this job.
/// </summary>
event EventHandler PeriodChanged;
/// <summary>
/// Executes the logic associated with the recurring background job asynchronously.
/// Runs the background job.
/// </summary>
/// <returns>A <see cref="System.Threading.Tasks.Task"/> that represents the asynchronous execution of the background job.</returns>
/// <returns>
/// A task representing the asynchronous operation.
/// </returns>
[Obsolete("Use RunJobAsync(CancellationToken) instead. Scheduled for removal in Umbraco 19.")]
Task RunJobAsync();
}
/// <summary>
/// Runs the background job with cancellation support.
/// </summary>
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
/// <returns>
/// A task representing the asynchronous operation.
/// </returns>
Task RunJobAsync(CancellationToken cancellationToken)
#pragma warning disable CS0618 // Type or member is obsolete
=> RunJobAsync(); // TODO (V19): Remove the default implementation when RunJobAsync() is removed
#pragma warning restore CS0618 // Type or member is obsolete
}
@@ -0,0 +1,43 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Umbraco.Cms.Infrastructure.HostedServices;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// Provides methods to signal a specific recurring background job to execute immediately.
/// </summary>
/// <typeparam name="TJob">The type of the recurring background job to trigger. Must implement <see cref="ITriggerableRecurringBackgroundJob" />.</typeparam>
public interface IRecurringBackgroundJobTrigger<TJob>
where TJob : ITriggerableRecurringBackgroundJob
{
/// <summary>
/// Signals the background loop to execute immediately.
/// After the triggered execution, the original schedule is kept.
/// </summary>
/// <returns>
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
/// </returns>
/// <seealso cref="NextExecutionStrategy.None" />
bool TriggerExecution();
/// <summary>
/// Signals the background loop to execute immediately, with the specified strategy for determining the next execution after the triggered one completes.
/// </summary>
/// <param name="strategy">Controls the delay after the triggered execution.</param>
/// <returns>
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
/// </returns>
bool TriggerExecution(NextExecutionStrategy strategy);
/// <summary>
/// Signals the background loop to execute immediately.
/// After the triggered execution, the next execution is scheduled after the specified delay (measured from execution start; execution time is subtracted to prevent drift).
/// </summary>
/// <param name="nextDelay">The target interval from execution start to the next execution. Execution time is subtracted to prevent drift.</param>
/// <returns>
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
/// </returns>
bool TriggerExecution(TimeSpan nextDelay);
}
@@ -0,0 +1,11 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// Marker interface for recurring background jobs that support being triggered manually.
/// Only jobs implementing this interface can be triggered via <see cref="IRecurringBackgroundJobTrigger{TJob}" />.
/// </summary>
public interface ITriggerableRecurringBackgroundJob : IRecurringBackgroundJob
{ }
@@ -0,0 +1,47 @@
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// Base class for recurring background jobs that provides default values for common properties.
/// </summary>
/// <remarks>
/// Implementors only need to provide <see cref="Period" /> and <see cref="RunJobAsync(CancellationToken)" />.
/// </remarks>
public abstract class RecurringBackgroundJobBase : IRecurringBackgroundJob
{
/// <summary>
/// The default delay to use for recurring tasks for the first run after application start-up if no alternative is configured.
/// </summary>
/// <remarks>
/// The default of 3 minutes is chosen to allow the application to finish starting up and stabilize before the first execution of recurring tasks.
/// </remarks>
protected internal static readonly TimeSpan DefaultDelay = TimeSpan.FromMinutes(3);
/// <summary>
/// The default server roles that recurring background jobs run on.
/// </summary>
/// <remarks>
/// The default of running on both <see cref="ServerRole.Single" /> and <see cref="ServerRole.SchedulingPublisher" /> is chosen to ensure recurring background jobs do not run on every server (in a load-balanced environment).
/// </remarks>
protected internal static readonly ServerRole[] DefaultServerRoles = [ServerRole.Single, ServerRole.SchedulingPublisher];
/// <inheritdoc />
public abstract TimeSpan Period { get; }
/// <inheritdoc />
public virtual TimeSpan Delay => DefaultDelay;
/// <inheritdoc />
public virtual ServerRole[] ServerRoles => DefaultServerRoles;
/// <inheritdoc />
public virtual event EventHandler PeriodChanged { add { } remove { } }
/// <inheritdoc />
[Obsolete("Use RunJobAsync(CancellationToken) instead. Scheduled for removal in Umbraco 19.")]
public Task RunJobAsync() => RunJobAsync(CancellationToken.None);
/// <inheritdoc />
public abstract Task RunJobAsync(CancellationToken cancellationToken);
}
@@ -1,16 +1,15 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Serilog.Core;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Runtime;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
using Umbraco.Cms.Infrastructure.HostedServices;
using Umbraco.Cms.Infrastructure.Notifications;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
@@ -23,32 +22,68 @@ public static class RecurringBackgroundJobHostedService
/// Creates a factory function that produces hosted services for recurring background jobs.
/// </summary>
/// <param name="serviceProvider">The service provider used to create hosted service instances.</param>
/// <returns>A function that takes an <see cref="IRecurringBackgroundJob"/> and returns an <see cref="IHostedService"/>.</returns>
public static Func<IRecurringBackgroundJob, IHostedService> CreateHostedServiceFactory(IServiceProvider serviceProvider) =>
(IRecurringBackgroundJob job) =>
/// <returns>
/// A function that takes an <see cref="IRecurringBackgroundJob" /> and returns an <see cref="IHostedService" />.
/// </returns>
public static Func<IRecurringBackgroundJob, IHostedService> CreateHostedServiceFactory(IServiceProvider serviceProvider)
=> (IRecurringBackgroundJob job) =>
{
Type hostedServiceType = typeof(RecurringBackgroundJobHostedService<>).MakeGenericType(job.GetType());
return (IHostedService)ActivatorUtilities.CreateInstance(serviceProvider, hostedServiceType, job);
};
}
/// <summary>
/// Runs a recurring background job inside a hosted service.
/// Generic version for DependencyInjection
/// </summary>
/// <typeparam name="TJob">Type of the Job</typeparam>
public class RecurringBackgroundJobHostedService<TJob> : RecurringHostedServiceBase where TJob : IRecurringBackgroundJob
/// <typeparam name="TJob">The type of the job.</typeparam>
public class RecurringBackgroundJobHostedService<TJob> : RecurringHostedServiceBase
where TJob : IRecurringBackgroundJob
{
private readonly IRuntimeState _runtimeState;
private readonly ILogger<RecurringBackgroundJobHostedService<TJob>> _logger;
private readonly IMainDom _mainDom;
private readonly IRuntimeState _runtimeState;
private readonly IServerRoleAccessor _serverRoleAccessor;
private readonly IEventAggregator _eventAggregator;
private readonly IEventMessagesFactory _eventMessagesFactory;
private readonly IRecurringBackgroundJob _job;
/// <summary>
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedService{TJob}"/> class, which manages the execution of a recurring background job.
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedService{TJob}" /> class, which manages the execution of a recurring background job.
/// </summary>
/// <param name="runtimeState">Provides information about the current runtime state of the Umbraco application.</param>
/// <param name="logger">The logger used to record diagnostic and operational information for this hosted service.</param>
/// <param name="mainDom">The main domain instance responsible for coordinating single-instance operations across multiple application domains.</param>
/// <param name="serverRoleAccessor">Determines the current server's role in a multi-server environment.</param>
/// <param name="eventAggregator">Handles the publishing and subscribing of application events.</param>
/// <param name="eventMessagesFactory">The event messages factory.</param>
/// <param name="job">The recurring background job instance to be managed and executed by this service.</param>
/// <param name="timeProvider">The time provider used for scheduling and elapsed time measurement.</param>
public RecurringBackgroundJobHostedService(
IRuntimeState runtimeState,
ILogger<RecurringBackgroundJobHostedService<TJob>> logger,
IMainDom mainDom,
IServerRoleAccessor serverRoleAccessor,
IEventAggregator eventAggregator,
IEventMessagesFactory eventMessagesFactory,
TJob job,
TimeProvider timeProvider)
: base(logger, job.Period, job.Delay, timeProvider)
{
_runtimeState = runtimeState;
_logger = logger;
_mainDom = mainDom;
_serverRoleAccessor = serverRoleAccessor;
_eventAggregator = eventAggregator;
_eventMessagesFactory = eventMessagesFactory;
_job = job;
_job.PeriodChanged += OnPeriodChanged;
}
/// <summary>
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedService{TJob}" /> class, which manages the execution of a recurring background job.
/// </summary>
/// <param name="runtimeState">Provides information about the current runtime state of the Umbraco application.</param>
/// <param name="logger">The logger used to record diagnostic and operational information for this hosted service.</param>
@@ -56,6 +91,7 @@ public class RecurringBackgroundJobHostedService<TJob> : RecurringHostedServiceB
/// <param name="serverRoleAccessor">Determines the current server's role in a multi-server environment.</param>
/// <param name="eventAggregator">Handles the publishing and subscribing of application events.</param>
/// <param name="job">The recurring background job instance to be managed and executed by this service.</param>
[Obsolete("Use the constructor accepting IEventMessagesFactory and TimeProvider instead. Scheduled for removal in Umbraco 19.")]
public RecurringBackgroundJobHostedService(
IRuntimeState runtimeState,
ILogger<RecurringBackgroundJobHostedService<TJob>> logger,
@@ -63,31 +99,22 @@ public class RecurringBackgroundJobHostedService<TJob> : RecurringHostedServiceB
IServerRoleAccessor serverRoleAccessor,
IEventAggregator eventAggregator,
TJob job)
: base(logger, job.Period, job.Delay)
{
_runtimeState = runtimeState;
_logger = logger;
_mainDom = mainDom;
_serverRoleAccessor = serverRoleAccessor;
_eventAggregator = eventAggregator;
_job = job;
_job.PeriodChanged += (sender, e) => ChangePeriod(_job.Period);
}
: this(runtimeState, logger, mainDom, serverRoleAccessor, eventAggregator, StaticServiceProvider.Instance.GetRequiredService<IEventMessagesFactory>(), job, TimeProvider.System)
{ }
/// <inheritdoc />
public override async Task PerformExecuteAsync(object? state)
public override async Task PerformExecuteAsync(CancellationToken stoppingToken)
{
var executingNotification = new Notifications.RecurringBackgroundJobExecutingNotification(_job, new EventMessages());
await _eventAggregator.PublishAsync(executingNotification);
EventMessages eventMessages = _eventMessagesFactory.Get();
var executingNotification = new RecurringBackgroundJobExecutingNotification(_job, eventMessages);
await _eventAggregator.PublishAsync(executingNotification, stoppingToken);
try
{
if (_runtimeState.Level != RuntimeLevel.Run)
{
_logger.LogDebug("Job not running as runlevel not yet ready");
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobIgnoredNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
await _eventAggregator.PublishAsync(new RecurringBackgroundJobIgnoredNotification(_job, eventMessages).WithStateFrom(executingNotification), stoppingToken);
return;
}
@@ -95,7 +122,7 @@ public class RecurringBackgroundJobHostedService<TJob> : RecurringHostedServiceB
if (!_job.ServerRoles.Contains(_serverRoleAccessor.CurrentServerRole))
{
_logger.LogDebug("Job not running on this server role");
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobIgnoredNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
await _eventAggregator.PublishAsync(new RecurringBackgroundJobIgnoredNotification(_job, eventMessages).WithStateFrom(executingNotification), stoppingToken);
return;
}
@@ -103,54 +130,69 @@ public class RecurringBackgroundJobHostedService<TJob> : RecurringHostedServiceB
if (!_mainDom.IsMainDom)
{
_logger.LogDebug("Job not running as not MainDom");
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobIgnoredNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
await _eventAggregator.PublishAsync(new RecurringBackgroundJobIgnoredNotification(_job, eventMessages).WithStateFrom(executingNotification), stoppingToken);
return;
}
await _job.RunJobAsync();
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobExecutedNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
await _job.RunJobAsync(stoppingToken);
await _eventAggregator.PublishAsync(new RecurringBackgroundJobExecutedNotification(_job, eventMessages).WithStateFrom(executingNotification), stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
_logger.LogDebug("Job canceled during shutdown.");
await _eventAggregator.PublishAsync(new RecurringBackgroundJobCanceledNotification(_job, eventMessages).WithStateFrom(executingNotification), CancellationToken.None);
}
catch (Exception ex)
{
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobFailedNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
_logger.LogError(ex, "Unhandled exception in recurring background job.");
await _eventAggregator.PublishAsync(new RecurringBackgroundJobFailedNotification(_job, eventMessages).WithStateFrom(executingNotification), stoppingToken);
}
}
/// <summary>
/// Asynchronously starts the recurring background job and publishes notifications before and after the job is started.
/// This method first publishes a <see cref="Notifications.RecurringBackgroundJobStartingNotification"/> prior to starting the job,
/// then calls the base implementation to start the job, and finally publishes a <see cref="Notifications.RecurringBackgroundJobStartedNotification"/>.
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A task that represents the asynchronous start operation.</returns>
/// <inheritdoc />
[Obsolete("Override PerformExecuteAsync(CancellationToken) instead. Scheduled for removal in Umbraco 19.")]
public override Task PerformExecuteAsync(object? state) => PerformExecuteAsync(CancellationToken.None);
/// <inheritdoc />
public override async Task StartAsync(CancellationToken cancellationToken)
{
var startingNotification = new Notifications.RecurringBackgroundJobStartingNotification(_job, new EventMessages());
await _eventAggregator.PublishAsync(startingNotification);
EventMessages eventMessages = _eventMessagesFactory.Get();
var startingNotification = new RecurringBackgroundJobStartingNotification(_job, eventMessages);
await _eventAggregator.PublishAsync(startingNotification, cancellationToken);
await base.StartAsync(cancellationToken);
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobStartedNotification(_job, new EventMessages()).WithStateFrom(startingNotification));
await _eventAggregator.PublishAsync(new RecurringBackgroundJobStartedNotification(_job, eventMessages).WithStateFrom(startingNotification), cancellationToken);
}
/// <summary>
/// Asynchronously stops the recurring background job service, publishing notifications before and after stopping.
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A task that represents the asynchronous stop operation.</returns>
/// <inheritdoc />
public override async Task StopAsync(CancellationToken cancellationToken)
{
var stoppingNotification = new Notifications.RecurringBackgroundJobStoppingNotification(_job, new EventMessages());
await _eventAggregator.PublishAsync(stoppingNotification);
EventMessages eventMessages = _eventMessagesFactory.Get();
var stoppingNotification = new RecurringBackgroundJobStoppingNotification(_job, eventMessages);
await _eventAggregator.PublishAsync(stoppingNotification, cancellationToken);
await base.StopAsync(cancellationToken);
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobStoppedNotification(_job, new EventMessages()).WithStateFrom(stoppingNotification));
await _eventAggregator.PublishAsync(new RecurringBackgroundJobStoppedNotification(_job, eventMessages).WithStateFrom(stoppingNotification), cancellationToken);
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
{
_job.PeriodChanged -= OnPeriodChanged;
}
base.Dispose(disposing);
}
/// <summary>
/// Handles the <see cref="IRecurringBackgroundJob.PeriodChanged" /> event by updating the base class period.
/// </summary>
/// <param name="sender">The sender.</param>
/// <param name="e">The <see cref="EventArgs"/> instance containing the event data.</param>
private void OnPeriodChanged(object? sender, EventArgs e)
=> ChangePeriod(_job.Period);
}
@@ -1,25 +1,26 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Infrastructure.HostedServices;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// A hosted service that discovers and starts hosted services for any recurring background jobs in the DI container.
/// A hosted service that discovers and starts hosted services for any recurring background jobs in the DI container.
/// </summary>
public class RecurringBackgroundJobHostedServiceRunner : IHostedService
{
private readonly ILogger<RecurringBackgroundJobHostedServiceRunner> _logger;
private readonly List<IRecurringBackgroundJob> _jobs;
private readonly Func<IRecurringBackgroundJob, IHostedService> _jobFactory;
private readonly List<NamedServiceJob> _hostedServices = new();
private readonly ConcurrentDictionary<Type, IHostedService> _hostedServices = new();
/// <summary>
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedServiceRunner"/> class.
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedServiceRunner" /> class.
/// </summary>
/// <param name="logger">An <see cref="ILogger{RecurringBackgroundJobHostedServiceRunner}"/> used for logging within the runner.</param>
/// <param name="jobs">A collection of <see cref="IRecurringBackgroundJob"/> instances to be managed by the runner.</param>
/// <param name="jobFactory">A factory function that creates an <see cref="IHostedService"/> for each <see cref="IRecurringBackgroundJob"/>.</param>
/// <param name="logger">An <see cref="ILogger{RecurringBackgroundJobHostedServiceRunner}" /> used for logging within the runner.</param>
/// <param name="jobs">A collection of <see cref="IRecurringBackgroundJob" /> instances to be managed by the runner.</param>
/// <param name="jobFactory">A factory function that creates an <see cref="IHostedService" /> for each <see cref="IRecurringBackgroundJob" />.</param>
public RecurringBackgroundJobHostedServiceRunner(
ILogger<RecurringBackgroundJobHostedServiceRunner> logger,
IEnumerable<IRecurringBackgroundJob> jobs,
@@ -30,80 +31,122 @@ public class RecurringBackgroundJobHostedServiceRunner : IHostedService
_jobFactory = jobFactory;
}
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting recurring background jobs hosted services");
foreach (IRecurringBackgroundJob job in _jobs)
{
var jobName = job.GetType().Name;
Type jobType = job.GetType();
var added = false;
try
{
IHostedService hostedService = _hostedServices.GetOrAdd(jobType, _ =>
{
_logger.LogDebug("Creating background hosted service for {JobTypeName}", jobType.Name);
_logger.LogDebug("Creating background hosted service for {job}", jobName);
IHostedService hostedService = _jobFactory(job);
IHostedService hostedService = _jobFactory(job);
added = true;
_logger.LogInformation("Starting a background hosted service for {job} with a delay of {delay}, running every {period}", jobName, job.Delay, job.Period);
return hostedService;
});
if (!added)
{
_logger.LogWarning("A background hosted service for {JobTypeName} is already registered, skipping duplicate", jobType.Name);
continue;
}
_logger.LogInformation("Starting a background hosted service for {JobTypeName} with a delay of {Delay}, running every {Period}", jobType.Name, job.Delay, job.Period);
await hostedService.StartAsync(cancellationToken).ConfigureAwait(false);
_hostedServices.Add(new NamedServiceJob(jobName, hostedService));
}
catch (Exception exception)
catch (Exception ex)
{
_logger.LogError(exception, "Failed to start background hosted service for {job}", jobName);
if (added)
{
// Ensure we don't stop hosted services that were not successfully started
_hostedServices.TryRemove(jobType, out _);
}
_logger.LogError(ex, "Failed to start background hosted service for {JobTypeName}", jobType.Name);
}
}
_logger.LogInformation("Completed starting recurring background jobs hosted services");
}
/// <summary>
/// Asynchronously stops all recurring background job hosted services managed by this runner.
/// </summary>
/// <param name="stoppingToken">A <see cref="CancellationToken"/> that can be used to cancel the stop operation.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous stop operation.</returns>
/// <inheritdoc />
public async Task StopAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Stopping recurring background jobs hosted services");
foreach (NamedServiceJob namedServiceJob in _hostedServices)
foreach (Type jobType in _hostedServices.Keys)
{
try
if (_hostedServices.TryRemove(jobType, out IHostedService? hostedService))
{
_logger.LogInformation("Stopping background hosted service for {job}", namedServiceJob.Name);
await namedServiceJob.HostedService.StopAsync(stoppingToken).ConfigureAwait(false);
}
catch (Exception exception)
{
_logger.LogError(exception, "Failed to stop background hosted service for {job}", namedServiceJob.Name);
try
{
_logger.LogInformation("Stopping background hosted service for {JobTypeName}", jobType.Name);
await hostedService.StopAsync(stoppingToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to stop background hosted service for {JobTypeName}", jobType.Name);
}
}
}
_logger.LogInformation("Completed stopping recurring background jobs hosted services");
}
private sealed class NamedServiceJob
/// <summary>
/// Signals the background loop for the specified job type to execute immediately, with the specified strategy for determining the next execution after the triggered one completes.
/// </summary>
/// <typeparam name="TJob">The type of the recurring background job to trigger.</typeparam>
/// <param name="strategy">Controls the delay after the triggered execution.</param>
/// <returns>
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
/// </returns>
internal bool TriggerExecution<TJob>(NextExecutionStrategy strategy)
where TJob : IRecurringBackgroundJob
{
/// <summary>
/// Initializes a new instance of the <see cref="NamedServiceJob"/> class using the specified job name and hosted service instance.
/// </summary>
/// <param name="name">The unique name identifying the job.</param>
/// <param name="hostedService">The <see cref="IHostedService"/> instance to be executed as the background job.</param>
public NamedServiceJob(string name, IHostedService hostedService)
if (FindHostedService<TJob>() is not { } hostedService)
{
Name = name;
HostedService = hostedService;
return false;
}
/// <summary>
/// Gets the unique name that identifies this background job.
/// </summary>
public string Name { get; }
hostedService.TriggerExecution(strategy);
/// <summary>
/// Gets the hosted service instance associated with the named service job.
/// </summary>
public IHostedService HostedService { get; }
return true;
}
/// <summary>
/// Signals the background loop for the specified job type to execute immediately.
/// After the triggered execution, the next execution is scheduled after the specified delay (measured from execution start; execution time is subtracted to prevent drift).
/// </summary>
/// <typeparam name="TJob">The type of the recurring background job to trigger.</typeparam>
/// <param name="nextDelay">The target interval from execution start to the next execution. Execution time is subtracted to prevent drift.</param>
/// <returns>
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
/// </returns>
internal bool TriggerExecution<TJob>(TimeSpan nextDelay)
where TJob : IRecurringBackgroundJob
{
if (FindHostedService<TJob>() is not { } hostedService)
{
return false;
}
hostedService.TriggerExecution(nextDelay);
return true;
}
private RecurringHostedServiceBase? FindHostedService<TJob>()
where TJob : IRecurringBackgroundJob
=> _hostedServices.TryGetValue(typeof(TJob), out IHostedService? service) ? service as RecurringHostedServiceBase : null;
}
@@ -0,0 +1,35 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Umbraco.Cms.Infrastructure.HostedServices;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// Default implementation of <see cref="IRecurringBackgroundJobTrigger{TJob}" /> that delegates to the hosted service runner.
/// </summary>
/// <typeparam name="TJob">The type of the recurring background job to trigger.</typeparam>
internal sealed class RecurringBackgroundJobTrigger<TJob> : IRecurringBackgroundJobTrigger<TJob>
where TJob : ITriggerableRecurringBackgroundJob
{
private readonly RecurringBackgroundJobHostedServiceRunner _runner;
/// <summary>
/// Initializes a new instance of the <see cref="RecurringBackgroundJobTrigger{TJob}" /> class.
/// </summary>
/// <param name="runner">The runner.</param>
public RecurringBackgroundJobTrigger(RecurringBackgroundJobHostedServiceRunner runner)
=> _runner = runner;
/// <inheritdoc />
public bool TriggerExecution()
=> TriggerExecution(NextExecutionStrategy.None);
/// <inheritdoc />
public bool TriggerExecution(NextExecutionStrategy strategy)
=> _runner.TriggerExecution<TJob>(strategy);
/// <inheritdoc />
public bool TriggerExecution(TimeSpan nextDelay)
=> _runner.TriggerExecution<TJob>(nextDelay);
}
@@ -38,7 +38,8 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddHostedService<DistributedBackgroundJobHostedService>();
builder.Services.AddSingleton(RecurringBackgroundJobHostedService.CreateHostedServiceFactory);
builder.Services.AddHostedService<RecurringBackgroundJobHostedServiceRunner>();
builder.Services.AddSingleton<RecurringBackgroundJobHostedServiceRunner>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<RecurringBackgroundJobHostedServiceRunner>());
builder.Services.AddHostedService<QueuedHostedService>();
builder.AddNotificationAsyncHandler<PostRuntimePremigrationsUpgradeNotification, NavigationInitializationNotificationHandler>();
builder.AddNotificationAsyncHandler<PostRuntimePremigrationsUpgradeNotification, PublishStatusInitializationNotificationHandler>();
@@ -1,6 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Infrastructure.BackgroundJobs;
namespace Umbraco.Extensions;
@@ -11,27 +9,48 @@ namespace Umbraco.Extensions;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds a recurring background job with an implementation type of
/// <typeparamref name="TJob" /> to the specified <see cref="IServiceCollection" />.
/// Adds a recurring background job with an implementation type of <typeparamref name="TJob" />.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection" /> to add the recurring background job to.</param>
public static void AddRecurringBackgroundJob<TJob>(
this IServiceCollection services)
where TJob : class, IRecurringBackgroundJob =>
services.AddSingleton<IRecurringBackgroundJob, TJob>();
where TJob : class, IRecurringBackgroundJob
=> services.AddSingleton<IRecurringBackgroundJob, TJob>();
/// <summary>
/// Adds a recurring background job with an implementation type of
/// <typeparamref name="TJob" /> using the factory <paramref name="implementationFactory"/>
/// to the specified <see cref="IServiceCollection" />.
/// Adds a recurring background job with an implementation type of <typeparamref name="TJob" /> using the factory <paramref name="implementationFactory" />.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection" /> to add the recurring background job to.</param>
/// <param name="implementationFactory">A factory function to create an instance of <typeparamref name="TJob" /> using the provided <see cref="IServiceProvider" />.</param>
public static void AddRecurringBackgroundJob<TJob>(
this IServiceCollection services,
Func<IServiceProvider, TJob> implementationFactory)
where TJob : class, IRecurringBackgroundJob =>
services.AddSingleton<IRecurringBackgroundJob, TJob>(implementationFactory);
where TJob : class, IRecurringBackgroundJob
=> services.AddSingleton<IRecurringBackgroundJob, TJob>(implementationFactory);
/// <summary>
/// Adds a triggerable recurring background job with an implementation type of <typeparamref name="TJob" /> and registers an <see cref="IRecurringBackgroundJobTrigger{TJob}" /> for it.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection" /> to add the recurring background job to.</param>
public static void AddTriggerableRecurringBackgroundJob<TJob>(
this IServiceCollection services)
where TJob : class, ITriggerableRecurringBackgroundJob
{
services.AddRecurringBackgroundJob<TJob>();
services.AddSingleton<IRecurringBackgroundJobTrigger<TJob>, RecurringBackgroundJobTrigger<TJob>>();
}
/// <summary>
/// Adds a triggerable recurring background job with an implementation type of <typeparamref name="TJob" /> using the factory <paramref name="implementationFactory" /> and registers an <see cref="IRecurringBackgroundJobTrigger{TJob}" /> for it.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection" /> to add the recurring background job to.</param>
/// <param name="implementationFactory">A factory function to create an instance of <typeparamref name="TJob" /> using the provided <see cref="IServiceProvider" />.</param>
public static void AddTriggerableRecurringBackgroundJob<TJob>(
this IServiceCollection services,
Func<IServiceProvider, TJob> implementationFactory)
where TJob : class, ITriggerableRecurringBackgroundJob
{
services.AddRecurringBackgroundJob(implementationFactory);
services.AddSingleton<IRecurringBackgroundJobTrigger<TJob>, RecurringBackgroundJobTrigger<TJob>>();
}
}
@@ -0,0 +1,30 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
namespace Umbraco.Cms.Infrastructure.HostedServices;
/// <summary>
/// Determines the next execution strategy after a manually triggered execution completes.
/// </summary>
public enum NextExecutionStrategy
{
/// <summary>
/// Keep the current scheduled run unchanged.
/// The next execution occurs at the originally-scheduled time.
/// If that time has already passed (e.g. the triggered execution took longer than the remaining wait), it is skipped and the next period tick is awaited instead.
/// </summary>
None,
/// <summary>
/// Reset the period: wait a full period after the triggered execution completes.
/// The triggered execution effectively shifts the schedule forward.
/// </summary>
Reset,
/// <summary>
/// The triggered execution replaces the next scheduled run.
/// The following execution occurs one full period after the originally-scheduled time.
/// Use this when the manual trigger is an early execution of the next scheduled run.
/// </summary>
Replace,
}
@@ -1,7 +1,6 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using System.Diagnostics;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
@@ -10,168 +9,236 @@ using Umbraco.Cms.Core.Configuration;
namespace Umbraco.Cms.Infrastructure.HostedServices;
/// <summary>
/// Provides a base class for recurring background tasks implemented as hosted services.
/// Provides a base class for recurring background tasks implemented as hosted services.
/// </summary>
/// <remarks>
/// See: <see href="https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-3.1&amp;tabs=visual-studio#timed-background-tasks"/>.
/// </remarks>
public abstract class RecurringHostedServiceBase : IHostedService, IDisposable
public abstract class RecurringHostedServiceBase : BackgroundService
{
/// <summary>
/// The default delay to use for recurring tasks for the first run after application start-up if no alternative is
/// configured.
/// The default delay to use for recurring tasks for the first run after application start-up if no alternative is configured.
/// </summary>
protected static readonly TimeSpan DefaultDelay = TimeSpan.FromMinutes(3);
private readonly TimeSpan _delay;
private readonly ILogger? _logger;
private bool _disposedValue;
private TimeSpan _period;
private Timer? _timer;
private readonly TimeProvider _timeProvider;
private readonly SemaphoreSlim _signal = new(0, 1);
private CancellationTokenSource _periodChangeCts = new();
private long _periodTicks;
private TriggerState _triggerState = TriggerState.Default;
private volatile bool _nextExecutionSkipOnOvershoot;
private int _isDisposed;
/// <summary>
/// Initializes a new instance of the <see cref="RecurringHostedServiceBase" /> class.
/// Initializes a new instance of the <see cref="RecurringHostedServiceBase" /> class.
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="period">Timespan representing how often the task should recur.</param>
/// <param name="delay">
/// Timespan representing the initial delay after application start-up before the first run of the task
/// occurs.
/// </param>
protected RecurringHostedServiceBase(ILogger? logger, TimeSpan period, TimeSpan delay)
/// <param name="delay">Timespan representing the initial delay after application start-up before the first run of the task occurs.</param>
/// <param name="timeProvider">The time provider used for scheduling and elapsed time measurement.</param>
protected RecurringHostedServiceBase(ILogger? logger, TimeSpan period, TimeSpan delay, TimeProvider timeProvider)
{
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(period, TimeSpan.Zero);
_logger = logger;
_period = period;
Interlocked.Exchange(ref _periodTicks, period.Ticks);
_delay = delay;
}
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
_timeProvider = timeProvider;
}
/// <summary>
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optonal
/// configuration for the first run time is available.
/// Initializes a new instance of the <see cref="RecurringHostedServiceBase" /> class.
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="period">Timespan representing how often the task should recur.</param>
/// <param name="delay">Timespan representing the initial delay after application start-up before the first run of the task occurs.</param>
[Obsolete("Use the constructor accepting TimeProvider. Scheduled for removal in Umbraco 19.")]
protected RecurringHostedServiceBase(ILogger? logger, TimeSpan period, TimeSpan delay)
: this(logger, period, delay, TimeProvider.System)
{ }
/// <summary>
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optional configuration for the first run time is available.
/// </summary>
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser"/></param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser" />.</param>
/// <param name="logger">The logger.</param>
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
/// <returns>The delay before first running the recurring task.</returns>
protected static TimeSpan GetDelay(
string firstRunTime,
ICronTabParser cronTabParser,
ILogger logger,
TimeSpan defaultDelay) => GetDelay(firstRunTime, cronTabParser, logger, DateTime.Now, defaultDelay);
/// <returns>
/// The delay before first running the recurring task.
/// </returns>
[Obsolete("Use DelayCalculator.GetDelay instead. Scheduled for removal in Umbraco 19.")]
protected static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, TimeSpan defaultDelay)
=> BackgroundJobs.DelayCalculator.GetDelay(firstRunTime, cronTabParser, logger, defaultDelay);
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Initial delay (also interruptible via signal)
if (_delay > TimeSpan.Zero)
{
try
{
bool signaled = await WaitForSignalAsync(_delay, CancellationToken.None, stoppingToken);
if (signaled)
{
// Trigger interrupted the initial delay — consume the trigger state, so it doesn't leak into WaitForNextExecutionAsync after the first execution
Interlocked.Exchange(ref _triggerState, TriggerState.Default);
_nextExecutionSkipOnOvershoot = false;
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
}
TimeSpan nextDelayBasis = ReadPeriod();
while (!stoppingToken.IsCancellationRequested)
{
long startTimestamp = _timeProvider.GetTimestamp();
try
{
await PerformExecuteAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
ILogger logger = _logger ?? StaticApplicationLogging.CreateLogger(GetType());
logger.LogError(ex, "Unhandled exception in recurring hosted service.");
}
TimeSpan executionElapsed = _timeProvider.GetElapsedTime(startTimestamp);
nextDelayBasis = await WaitForNextExecutionAsync(nextDelayBasis, executionElapsed, stoppingToken);
}
}
/// <summary>
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optonal
/// configuration for the first run time is available.
/// Waits for the remaining period (minus execution time) before the next execution.
/// If <see cref="TriggerExecution()" /> is called, the wait exits immediately and returns the delay basis for the execution after the triggered one.
/// </summary>
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser"/></param>
/// <param name="logger">The logger.</param>
/// <param name="now">The current datetime.</param>
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
/// <returns>The delay before first running the recurring task.</returns>
/// <remarks>Internal to expose for unit tests.</remarks>
internal static TimeSpan GetDelay(
string firstRunTime,
ICronTabParser cronTabParser,
ILogger logger,
DateTime now,
TimeSpan defaultDelay)
/// <param name="delayBasis">The delay basis.</param>
/// <param name="executionElapsed">The execution elapsed.</param>
/// <param name="stoppingToken">The stopping token.</param>
/// <returns>
/// The delay basis to use for the next wait cycle.
/// </returns>
private async Task<TimeSpan> WaitForNextExecutionAsync(TimeSpan delayBasis, TimeSpan executionElapsed, CancellationToken stoppingToken)
{
// If first run time not set, start with just small delay after application start.
if (string.IsNullOrEmpty(firstRunTime))
TimeSpan period = ReadPeriod();
TimeSpan delay = ComputeNextDelay(delayBasis, executionElapsed);
// If the delay basis was from a NextExecutionStrategy.None trigger and the execution overshot the scheduled time,
// advance to the next period tick instead of executing immediately.
// The flag is consumed unconditionally so it never leaks into later cycles.
bool skipOnOvershoot = _nextExecutionSkipOnOvershoot;
_nextExecutionSkipOnOvershoot = false;
if (delay <= TimeSpan.Zero && skipOnOvershoot)
{
return defaultDelay;
delay = ComputeNextDelay(delayBasis + period, executionElapsed);
}
// If first run time not a valid cron tab, log, and revert to small delay after application start.
if (!cronTabParser.IsValidCronTab(firstRunTime))
if (delay <= TimeSpan.Zero)
{
logger.LogWarning("Could not parse {FirstRunTime} as a crontab expression. Defaulting to default delay for hosted service start.", firstRunTime);
return defaultDelay;
return period;
}
// Otherwise start at scheduled time according to cron expression, unless within the default delay period.
DateTime firstRunOccurance = cronTabParser.GetNextOccurrence(firstRunTime, now);
TimeSpan delay = firstRunOccurance - now;
return delay < defaultDelay
? defaultDelay
: delay;
}
long waitStart = _timeProvider.GetTimestamp();
/// <inheritdoc />
public virtual Task StartAsync(CancellationToken cancellationToken)
{
using (!ExecutionContext.IsFlowSuppressed() ? (IDisposable)ExecutionContext.SuppressFlow() : null)
while (true)
{
_timer = new Timer(ExecuteAsync, null, _delay, _period);
CancellationToken periodChangeToken = _periodChangeCts.Token;
bool signaled;
try
{
signaled = await WaitForSignalAsync(delay, periodChangeToken, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return ReadPeriod();
}
if (!signaled && periodChangeToken.IsCancellationRequested)
{
// Period changed — re-read and recalculate remaining delay with the new period.
period = ReadPeriod();
TimeSpan totalElapsed = executionElapsed + _timeProvider.GetElapsedTime(waitStart);
delay = ComputeNextDelay(period, totalElapsed);
if (delay <= TimeSpan.Zero)
{
return period;
}
continue;
}
if (!signaled)
{
return period; // Normal timeout — next wait uses normal period
}
TriggerState triggerState = Interlocked.Exchange(ref _triggerState, TriggerState.Default);
if (triggerState.Delay.HasValue)
{
return triggerState.Delay.Value;
}
TimeSpan waitElapsed = _timeProvider.GetElapsedTime(waitStart);
TimeSpan remaining = ComputeNextDelay(delay, waitElapsed);
switch (triggerState.Strategy)
{
case NextExecutionStrategy.None:
_nextExecutionSkipOnOvershoot = true;
return remaining;
case NextExecutionStrategy.Replace:
return remaining + period;
case NextExecutionStrategy.Reset:
default:
return period;
}
}
return Task.CompletedTask;
}
/// <inheritdoc />
public virtual Task StopAsync(CancellationToken cancellationToken)
{
_period = Timeout.InfiniteTimeSpan;
_timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
/// <summary>
/// Executes the task.
/// Implements the work of the recurring task.
/// </summary>
/// <param name="stoppingToken">A cancellation token that is signaled when the host is shutting down.</param>
/// <returns>
/// A task representing the asynchronous operation.
/// </returns>
public virtual Task PerformExecuteAsync(CancellationToken stoppingToken)
#pragma warning disable CS0618 // Type or member is obsolete
=> PerformExecuteAsync(null);
#pragma warning restore CS0618 // Type or member is obsolete
/// <summary>
/// Implements the work of the recurring task.
/// </summary>
/// <param name="state">The task state.</param>
public virtual async void ExecuteAsync(object? state)
{
var sw = Stopwatch.StartNew();
try
{
// First, stop the timer, we do not want tasks to execute in parallel
_timer?.Change(Timeout.Infinite, 0);
// Delegate work to method returning a task, that can be called and asserted in a unit test.
// Without this there can be behaviour where tests pass, but an error within them causes the test
// running process to crash.
// Hat-tip: https://stackoverflow.com/a/14207615/489433
await PerformExecuteAsync(state);
}
catch (Exception ex)
{
ILogger logger = _logger ?? StaticApplicationLogging.CreateLogger(GetType());
logger.LogError(ex, "Unhandled exception in recurring hosted service.");
}
finally
{
sw.Stop();
// If the service has been stopped, _period is set to InfiniteTimeSpan in StopAsync.
// Preserve it to keep the timer disabled.
TimeSpan remaining = _period == Timeout.InfiniteTimeSpan
? Timeout.InfiniteTimeSpan
: ComputeNextDelay(_period, sw.Elapsed);
_timer?.Change(remaining, _period);
}
}
/// <returns>
/// A task representing the asynchronous operation.
/// </returns>
/// <remarks>
/// This overload does not receive a <see cref="CancellationToken" />, so shutdown cancellation is not propagated to the implementation.
/// </remarks>
[Obsolete("Override PerformExecuteAsync(CancellationToken) instead. Scheduled for removal in Umbraco 19.")]
public virtual Task PerformExecuteAsync(object? state)
=> Task.CompletedTask;
/// <summary>
/// Executes the core logic of the recurring hosted service asynchronously.
/// Executes the task.
/// </summary>
/// <param name="state">An optional object containing state information for the execution.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous execution of the recurring task.</returns>
public abstract Task PerformExecuteAsync(object? state);
/// <param name="state">The task state.</param>
[Obsolete("No longer used. The base class now uses BackgroundService.ExecuteAsync(CancellationToken). Scheduled for removal in Umbraco 19.")]
public virtual void ExecuteAsync(object? state)
{ }
/// <summary>
/// Computes the delay before the next execution, subtracting the elapsed execution time from the period to prevent drift.
/// Clamps to <see cref="TimeSpan.Zero" /> if execution exceeded the period.
/// </summary>
/// <param name="period">The configured period between executions.</param>
/// <param name="elapsed">The elapsed time of the current execution.</param>
@@ -185,28 +252,143 @@ public abstract class RecurringHostedServiceBase : IHostedService, IDisposable
{
TimeSpan remaining = period - elapsed;
// A negative period (e.g. Timeout.InfiniteTimeSpan = -1ms, set by StopAsync) will always produce a
// negative remaining value. The caller in ExecuteAsync guards against this by checking for InfiniteTimeSpan
// before calling this method, to avoid scheduling an extra execution after stop.
return remaining < TimeSpan.Zero ? TimeSpan.Zero : remaining;
}
/// <summary>
/// Change the period between operations.
/// Change the period between operations. The new period takes effect immediately, interrupting the current wait if necessary.
/// </summary>
/// <param name="newPeriod">The new period between tasks</param>
protected void ChangePeriod(TimeSpan newPeriod) => _period = newPeriod;
protected virtual void Dispose(bool disposing)
/// <param name="newPeriod">The new period between tasks.</param>
protected void ChangePeriod(TimeSpan newPeriod)
{
if (!_disposedValue)
{
if (disposing)
{
_timer?.Dispose();
}
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(newPeriod, TimeSpan.Zero);
_disposedValue = true;
Interlocked.Exchange(ref _periodTicks, newPeriod.Ticks);
// Cancel but don't dispose — the wait loop may still be registering against the token.
// The old CTS is small once cancelled and will be collected by the GC.
CancellationTokenSource oldCts = Interlocked.Exchange(ref _periodChangeCts, new CancellationTokenSource());
oldCts.Cancel();
}
/// <summary>
/// Signals the background loop to execute immediately.
/// After the triggered execution, the original schedule is kept.
/// If the scheduled time has already passed during the triggered execution, it is skipped and the next period tick is awaited.
/// </summary>
/// <seealso cref="NextExecutionStrategy.None" />
protected internal void TriggerExecution()
=> TriggerExecution(NextExecutionStrategy.None);
/// <summary>
/// Signals the background loop to execute immediately, with the specified strategy for determining the next execution after the triggered one completes.
/// </summary>
/// <param name="strategy">Controls the delay after the triggered execution.</param>
protected internal void TriggerExecution(NextExecutionStrategy strategy)
{
Interlocked.Exchange(ref _triggerState, new TriggerState(Strategy: strategy));
ReleaseSignal();
}
/// <summary>
/// Signals the background loop to execute immediately.
/// After the triggered execution, the next execution is scheduled after the specified delay (measured from execution start; execution time is subtracted to prevent drift).
/// </summary>
/// <param name="nextDelay">The target interval from execution start to the next execution. Execution time is subtracted to prevent drift.</param>
protected internal void TriggerExecution(TimeSpan nextDelay)
{
Interlocked.Exchange(ref _triggerState, new TriggerState(Delay: nextDelay));
ReleaseSignal();
}
/// <summary>
/// Reads the current period in a thread-safe manner.
/// </summary>
/// <returns>
/// The current period between executions.
/// </returns>
private TimeSpan ReadPeriod()
=> TimeSpan.FromTicks(Interlocked.Read(ref _periodTicks));
/// <summary>
/// Waits for the semaphore to be signaled or for the timeout to expire, using the injected <see cref="TimeProvider" />.
/// </summary>
/// <param name="timeout">The maximum time to wait.</param>
/// <param name="periodChangeToken">A cancellation token that is signaled when the period changes.</param>
/// <param name="stoppingToken">A cancellation token for shutdown.</param>
/// <returns>
/// <c>true</c> if the semaphore was signaled; <c>false</c> if the timeout expired or the period changed.
/// </returns>
/// <exception cref="OperationCanceledException">Thrown when <paramref name="stoppingToken" /> is cancelled.</exception>
private async Task<bool> WaitForSignalAsync(TimeSpan timeout, CancellationToken periodChangeToken, CancellationToken stoppingToken)
{
using var timeoutCts = new CancellationTokenSource(timeout, _timeProvider);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, periodChangeToken, stoppingToken);
try
{
await _signal.WaitAsync(linkedCts.Token);
return true;
}
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
{
return false; // Timeout expired or period changed
}
}
/// <summary>
/// Releases the semaphore to wake the background loop. If the semaphore is already signaled, the call is a no-op.
/// </summary>
private void ReleaseSignal()
{
try
{
_signal.Release();
}
catch (SemaphoreFullException)
{
// Already signaled
}
}
/// <inheritdoc />
public sealed override void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases unmanaged and optionally managed resources.
/// </summary>
/// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
protected virtual void Dispose(bool disposing)
{
if (Interlocked.CompareExchange(ref _isDisposed, 1, 0) != 0)
{
return;
}
if (disposing)
{
_signal.Dispose();
_periodChangeCts.Dispose();
}
base.Dispose();
}
/// <summary>
/// Immutable snapshot of the trigger state.
/// </summary>
private sealed record TriggerState(NextExecutionStrategy Strategy = default, TimeSpan? Delay = null)
{
/// <summary>
/// Gets the default trigger state with no strategy and no custom delay.
/// </summary>
/// <value>
/// The default trigger state.
/// </value>
public static TriggerState Default { get; } = new();
}
}
@@ -0,0 +1,19 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Infrastructure.BackgroundJobs;
namespace Umbraco.Cms.Infrastructure.Notifications;
/// <summary>
/// Notification that is raised when a recurring background job is cancelled during host shutdown.
/// </summary>
public sealed class RecurringBackgroundJobCanceledNotification : RecurringBackgroundJobNotification
{
/// <summary>
/// Initializes a new instance of the <see cref="RecurringBackgroundJobCanceledNotification" /> class.
/// </summary>
/// <param name="target">The instance of the recurring background job that was cancelled.</param>
/// <param name="messages">The <see cref="EventMessages" /> associated with the cancellation.</param>
public RecurringBackgroundJobCanceledNotification(IRecurringBackgroundJob target, EventMessages messages)
: base(target, messages)
{ }
}
+1
View File
@@ -7,6 +7,7 @@
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.4.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageVersion Include="System.Data.DataSetExtensions" Version="4.5.0" />
<PackageVersion Include="System.Data.Odbc" Version="10.0.4" />
@@ -0,0 +1,91 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Configuration;
using Umbraco.Cms.Infrastructure.BackgroundJobs;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs;
[TestFixture]
public class DelayCalculatorTests
{
[TestCase("30 12 * * *", 30)]
[TestCase("15 18 * * *", (60 * 6) + 15)]
[TestCase("0 3 * * *", 60 * 15)]
[TestCase("0 3 2 * *", (24 * 60 * 1) + (60 * 15))]
[TestCase("0 6 * * 3", (24 * 60 * 3) + (60 * 18))]
public void GetDelay_Returns_Delay_From_CronTab(string firstRunTime, int expectedDelayInMinutes)
{
var cronTabParser = new NCronTabParser();
var logger = Mock.Of<ILogger>();
var now = new DateTime(2020, 10, 31, 12, 0, 0);
TimeSpan result = DelayCalculator.GetDelay(firstRunTime, cronTabParser, logger, now, TimeSpan.Zero);
Assert.AreEqual(expectedDelayInMinutes, result.TotalMinutes);
}
[Test]
public void GetDelay_Returns_Default_When_CronTab_Too_Close_To_Current_Time()
{
var cronTabParser = new NCronTabParser();
var logger = Mock.Of<ILogger>();
var now = new DateTime(2020, 10, 31, 12, 25, 0);
var defaultDelay = TimeSpan.FromMinutes(10);
TimeSpan result = DelayCalculator.GetDelay("30 12 * * *", cronTabParser, logger, now, defaultDelay);
Assert.AreEqual(defaultDelay.TotalMinutes, result.TotalMinutes);
}
[Test]
public void GetDelay_Returns_Default_When_FirstRunTime_Is_Empty()
{
var cronTabParser = new NCronTabParser();
var logger = Mock.Of<ILogger>();
var now = new DateTime(2020, 10, 31, 12, 0, 0);
var defaultDelay = TimeSpan.FromMinutes(3);
TimeSpan result = DelayCalculator.GetDelay(string.Empty, cronTabParser, logger, now, defaultDelay);
Assert.AreEqual(defaultDelay, result);
}
[Test]
public void GetDelay_Logs_Warning_And_Returns_Default_When_CronTab_Is_Invalid()
{
var cronTabParser = new NCronTabParser();
var logger = new Mock<ILogger>();
var now = new DateTime(2020, 10, 31, 12, 25, 0);
var defaultDelay = TimeSpan.FromMinutes(10);
TimeSpan result = DelayCalculator.GetDelay("invalid", cronTabParser, logger.Object, now, defaultDelay);
Assert.AreEqual(defaultDelay, result);
logger.Verify(
l => l.Log(
It.Is<LogLevel>(y => y == LogLevel.Warning),
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception, string>>()),
Times.Once);
}
[Test]
public void GetDelay_With_TimeProvider_Uses_Provider_Time()
{
var cronTabParser = new NCronTabParser();
var logger = Mock.Of<ILogger>();
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2020, 10, 31, 12, 0, 0, TimeSpan.Zero));
// "30 12 * * *" = 12:30 daily. From 12:00, that's 30 minutes.
TimeSpan result = DelayCalculator.GetDelay("30 12 * * *", cronTabParser, logger, timeProvider, TimeSpan.Zero);
Assert.AreEqual(30, result.TotalMinutes);
}
}
@@ -0,0 +1,153 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Infrastructure.BackgroundJobs;
using Umbraco.Cms.Infrastructure.HostedServices;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs;
[TestFixture]
public class RecurringBackgroundJobHostedServiceRunnerTests
{
[Test]
public async Task TriggerExecution_Returns_True_When_Job_Is_Running()
{
var sut = CreateRunner(new TestJobA());
await sut.StartAsync(CancellationToken.None);
bool result = sut.TriggerExecution<TestJobA>(NextExecutionStrategy.None);
Assert.IsTrue(result);
await StopAsync(sut);
}
[Test]
public async Task TriggerExecution_Returns_False_When_Job_Is_Not_Registered()
{
var sut = CreateRunner(new TestJobA());
await sut.StartAsync(CancellationToken.None);
bool result = sut.TriggerExecution<TestJobB>(NextExecutionStrategy.None);
Assert.IsFalse(result);
await StopAsync(sut);
}
[Test]
public async Task TriggerExecution_Returns_False_Before_StartAsync()
{
var sut = CreateRunner(new TestJobA());
bool result = sut.TriggerExecution<TestJobA>(NextExecutionStrategy.None);
Assert.IsFalse(result);
}
[Test]
public async Task TriggerExecution_With_Strategy_Returns_True_When_Job_Is_Running()
{
var sut = CreateRunner(new TestJobA());
await sut.StartAsync(CancellationToken.None);
bool result = sut.TriggerExecution<TestJobA>(NextExecutionStrategy.Reset);
Assert.IsTrue(result);
await StopAsync(sut);
}
[Test]
public async Task TriggerExecution_With_Delay_Returns_True_When_Job_Is_Running()
{
var sut = CreateRunner(new TestJobA());
await sut.StartAsync(CancellationToken.None);
bool result = sut.TriggerExecution<TestJobA>(TimeSpan.FromSeconds(10));
Assert.IsTrue(result);
await StopAsync(sut);
}
[Test]
public async Task TriggerExecution_Causes_Immediate_Execution()
{
var executionCount = 0;
using var executed = new SemaphoreSlim(0);
var job = new TestJobA(onExecute: _ => { Interlocked.Increment(ref executionCount); executed.Release(); return Task.CompletedTask; });
var sut = CreateRunner(job);
await sut.StartAsync(CancellationToken.None);
// Wait for first execution (no delay on TestJobA)
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)), "First execution should complete");
Assert.AreEqual(1, executionCount, "Should have executed once initially");
// Trigger — period is 30s, so without trigger we wouldn't get another
sut.TriggerExecution<TestJobA>(NextExecutionStrategy.None);
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)), "Triggered execution should complete");
Assert.AreEqual(2, executionCount, "Should have executed again after trigger");
await StopAsync(sut);
}
private static RecurringBackgroundJobHostedServiceRunner CreateRunner(params IRecurringBackgroundJob[] jobs)
{
var logger = Mock.Of<ILogger<RecurringBackgroundJobHostedServiceRunner>>();
Func<IRecurringBackgroundJob, IHostedService> factory = job =>
new TestHostedService(job.Period, job.Delay, job, TimeProvider.System);
return new RecurringBackgroundJobHostedServiceRunner(logger, jobs, factory);
}
private static async Task StopAsync(RecurringBackgroundJobHostedServiceRunner runner)
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await runner.StopAsync(cts.Token);
}
private class TestJobA : RecurringBackgroundJobBase
{
private readonly Func<CancellationToken, Task>? _onExecute;
public TestJobA(Func<CancellationToken, Task>? onExecute = null) => _onExecute = onExecute;
public override TimeSpan Period => TimeSpan.FromSeconds(30);
public override TimeSpan Delay => TimeSpan.Zero;
public override Task RunJobAsync(CancellationToken cancellationToken)
=> _onExecute?.Invoke(cancellationToken) ?? Task.CompletedTask;
}
private class TestJobB : RecurringBackgroundJobBase
{
public override TimeSpan Period => TimeSpan.FromSeconds(30);
public override TimeSpan Delay => TimeSpan.Zero;
public override Task RunJobAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
/// <summary>
/// Minimal hosted service that wraps a job, inheriting from RecurringHostedServiceBase
/// so the runner can cast and call TriggerExecution.
/// </summary>
private class TestHostedService : RecurringHostedServiceBase
{
private readonly IRecurringBackgroundJob _job;
public TestHostedService(TimeSpan period, TimeSpan delay, IRecurringBackgroundJob job, TimeProvider timeProvider)
: base(null, period, delay, timeProvider)
=> _job = job;
public override Task PerformExecuteAsync(CancellationToken stoppingToken)
=> _job.RunJobAsync(stoppingToken);
}
}
@@ -5,14 +5,11 @@ using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Runtime;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
using Umbraco.Cms.Infrastructure.BackgroundJobs;
using Umbraco.Cms.Infrastructure.HostedServices;
using Umbraco.Cms.Infrastructure.Notifications;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs;
@@ -20,7 +17,6 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs;
[TestFixture]
public class RecurringBackgroundJobHostedServiceTests
{
[TestCase(RuntimeLevel.Boot)]
[TestCase(RuntimeLevel.Install)]
[TestCase(RuntimeLevel.Unknown)]
@@ -31,9 +27,9 @@ public class RecurringBackgroundJobHostedServiceTests
var mockJob = new Mock<IRecurringBackgroundJob>();
var sut = CreateRecurringBackgroundJobHostedService(mockJob, runtimeLevel: runtimeLevel);
await sut.PerformExecuteAsync(null);
await sut.PerformExecuteAsync(CancellationToken.None);
mockJob.Verify(job => job.RunJobAsync(), Times.Never);
mockJob.Verify(job => job.RunJobAsync(It.IsAny<CancellationToken>()), Times.Never);
}
[Test]
@@ -43,7 +39,7 @@ public class RecurringBackgroundJobHostedServiceTests
var mockEventAggregator = new Mock<IEventAggregator>();
var sut = CreateRecurringBackgroundJobHostedService(mockJob, runtimeLevel: RuntimeLevel.Unknown, mockEventAggregator: mockEventAggregator);
await sut.PerformExecuteAsync(null);
await sut.PerformExecuteAsync(CancellationToken.None);
mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny<RecurringBackgroundJobExecutingNotification>(), It.IsAny<CancellationToken>()), Times.Once);
mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny<RecurringBackgroundJobIgnoredNotification>(), It.IsAny<CancellationToken>()), Times.Once);
@@ -56,9 +52,9 @@ public class RecurringBackgroundJobHostedServiceTests
var mockJob = new Mock<IRecurringBackgroundJob>();
var sut = CreateRecurringBackgroundJobHostedService(mockJob, serverRole: serverRole);
await sut.PerformExecuteAsync(null);
await sut.PerformExecuteAsync(CancellationToken.None);
mockJob.Verify(job => job.RunJobAsync(), Times.Never);
mockJob.Verify(job => job.RunJobAsync(It.IsAny<CancellationToken>()), Times.Never);
}
[TestCase(ServerRole.Single)]
@@ -66,12 +62,12 @@ public class RecurringBackgroundJobHostedServiceTests
public async Task Does_Executes_When_Server_Role_Is_Default(ServerRole serverRole)
{
var mockJob = new Mock<IRecurringBackgroundJob>();
mockJob.Setup(x => x.ServerRoles).Returns(IRecurringBackgroundJob.DefaultServerRoles);
mockJob.Setup(x => x.ServerRoles).Returns(RecurringBackgroundJobBase.DefaultServerRoles);
var sut = CreateRecurringBackgroundJobHostedService(mockJob, serverRole: serverRole);
await sut.PerformExecuteAsync(null);
await sut.PerformExecuteAsync(CancellationToken.None);
mockJob.Verify(job => job.RunJobAsync(), Times.Once);
mockJob.Verify(job => job.RunJobAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Test]
@@ -81,9 +77,9 @@ public class RecurringBackgroundJobHostedServiceTests
mockJob.Setup(x => x.ServerRoles).Returns(new ServerRole[] { ServerRole.Subscriber });
var sut = CreateRecurringBackgroundJobHostedService(mockJob, serverRole: ServerRole.Subscriber);
await sut.PerformExecuteAsync(null);
await sut.PerformExecuteAsync(CancellationToken.None);
mockJob.Verify(job => job.RunJobAsync(), Times.Once);
mockJob.Verify(job => job.RunJobAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Test]
@@ -93,7 +89,7 @@ public class RecurringBackgroundJobHostedServiceTests
var mockEventAggregator = new Mock<IEventAggregator>();
var sut = CreateRecurringBackgroundJobHostedService(mockJob, serverRole: ServerRole.Unknown, mockEventAggregator: mockEventAggregator);
await sut.PerformExecuteAsync(null);
await sut.PerformExecuteAsync(CancellationToken.None);
mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny<RecurringBackgroundJobExecutingNotification>(), It.IsAny<CancellationToken>()), Times.Once);
mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny<RecurringBackgroundJobIgnoredNotification>(), It.IsAny<CancellationToken>()), Times.Once);
@@ -105,9 +101,9 @@ public class RecurringBackgroundJobHostedServiceTests
var mockJob = new Mock<IRecurringBackgroundJob>();
var sut = CreateRecurringBackgroundJobHostedService(mockJob, isMainDom: false);
await sut.PerformExecuteAsync(null);
await sut.PerformExecuteAsync(CancellationToken.None);
mockJob.Verify(job => job.RunJobAsync(), Times.Never);
mockJob.Verify(job => job.RunJobAsync(It.IsAny<CancellationToken>()), Times.Never);
}
[Test]
@@ -117,23 +113,21 @@ public class RecurringBackgroundJobHostedServiceTests
var mockEventAggregator = new Mock<IEventAggregator>();
var sut = CreateRecurringBackgroundJobHostedService(mockJob, isMainDom: false, mockEventAggregator: mockEventAggregator);
await sut.PerformExecuteAsync(null);
await sut.PerformExecuteAsync(CancellationToken.None);
mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny<RecurringBackgroundJobExecutingNotification>(), It.IsAny<CancellationToken>()), Times.Once);
mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny<RecurringBackgroundJobIgnoredNotification>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Test]
public async Task Publishes_Executed_Notification_When_Run()
{
var mockJob = new Mock<IRecurringBackgroundJob>();
mockJob.Setup(x => x.ServerRoles).Returns(IRecurringBackgroundJob.DefaultServerRoles);
mockJob.Setup(x => x.ServerRoles).Returns(RecurringBackgroundJobBase.DefaultServerRoles);
var mockEventAggregator = new Mock<IEventAggregator>();
var sut = CreateRecurringBackgroundJobHostedService(mockJob, mockEventAggregator: mockEventAggregator);
await sut.PerformExecuteAsync(null);
await sut.PerformExecuteAsync(CancellationToken.None);
mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny<RecurringBackgroundJobExecutingNotification>(), It.IsAny<CancellationToken>()), Times.Once);
mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny<RecurringBackgroundJobExecutedNotification>(), It.IsAny<CancellationToken>()), Times.Once);
@@ -143,17 +137,34 @@ public class RecurringBackgroundJobHostedServiceTests
public async Task Publishes_Failed_Notification_When_Fails()
{
var mockJob = new Mock<IRecurringBackgroundJob>();
mockJob.Setup(x => x.ServerRoles).Returns(IRecurringBackgroundJob.DefaultServerRoles);
mockJob.Setup(x => x.RunJobAsync()).Throws<Exception>();
mockJob.Setup(x => x.ServerRoles).Returns(RecurringBackgroundJobBase.DefaultServerRoles);
mockJob.Setup(x => x.RunJobAsync(It.IsAny<CancellationToken>())).Throws<Exception>();
var mockEventAggregator = new Mock<IEventAggregator>();
var sut = CreateRecurringBackgroundJobHostedService(mockJob, mockEventAggregator: mockEventAggregator);
await sut.PerformExecuteAsync(null);
await sut.PerformExecuteAsync(CancellationToken.None);
mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny<RecurringBackgroundJobExecutingNotification>(), It.IsAny<CancellationToken>()), Times.Once);
mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny<RecurringBackgroundJobFailedNotification>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Test]
public async Task Publishes_Canceled_Notification_When_Canceled()
{
using var cts = new CancellationTokenSource();
var mockJob = new Mock<IRecurringBackgroundJob>();
mockJob.Setup(x => x.ServerRoles).Returns(RecurringBackgroundJobBase.DefaultServerRoles);
mockJob.Setup(x => x.RunJobAsync(It.IsAny<CancellationToken>()))
.Returns<CancellationToken>(ct => { cts.Cancel(); ct.ThrowIfCancellationRequested(); return Task.CompletedTask; });
var mockEventAggregator = new Mock<IEventAggregator>();
var sut = CreateRecurringBackgroundJobHostedService(mockJob, mockEventAggregator: mockEventAggregator);
await sut.PerformExecuteAsync(cts.Token);
mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny<RecurringBackgroundJobExecutingNotification>(), It.IsAny<CancellationToken>()), Times.Once);
mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny<RecurringBackgroundJobCanceledNotification>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Test]
public async Task Publishes_Start_And_Stop_Notifications()
{
@@ -164,24 +175,22 @@ public class RecurringBackgroundJobHostedServiceTests
await sut.StartAsync(CancellationToken.None);
await sut.StopAsync(CancellationToken.None);
mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny<RecurringBackgroundJobStartingNotification>(), It.IsAny<CancellationToken>()), Times.Once);
mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny<RecurringBackgroundJobStartedNotification>(), It.IsAny<CancellationToken>()), Times.Once);
mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny<RecurringBackgroundJobStoppingNotification>(), It.IsAny<CancellationToken>()), Times.Once);
mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny<RecurringBackgroundJobStoppedNotification>(), It.IsAny<CancellationToken>()), Times.Once);
}
private RecurringHostedServiceBase CreateRecurringBackgroundJobHostedService(
private RecurringBackgroundJobHostedService<IRecurringBackgroundJob> CreateRecurringBackgroundJobHostedService(
Mock<IRecurringBackgroundJob> mockJob,
RuntimeLevel runtimeLevel = RuntimeLevel.Run,
ServerRole serverRole = ServerRole.Single,
bool isMainDom = true,
Mock<IEventAggregator> mockEventAggregator = null)
{
mockJob.Setup(x => x.Period).Returns(TimeSpan.FromMinutes(5));
mockJob.Setup(x => x.Delay).Returns(TimeSpan.Zero);
var mockRunTimeState = new Mock<IRuntimeState>();
mockRunTimeState.SetupGet(x => x.Level).Returns(runtimeLevel);
@@ -203,6 +212,8 @@ public class RecurringBackgroundJobHostedServiceTests
mockMainDom.Object,
mockServerRegistrar.Object,
mockEventAggregator.Object,
mockJob.Object);
Mock.Of<IEventMessagesFactory>(f => f.Get() == new EventMessages()),
mockJob.Object,
TimeProvider.System);
}
}
@@ -1,10 +1,8 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Logging;
using Moq;
using Microsoft.Extensions.Time.Testing;
using NUnit.Framework;
using Umbraco.Cms.Core.Configuration;
using Umbraco.Cms.Infrastructure.HostedServices;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HostedServices;
@@ -12,105 +10,408 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HostedServices;
[TestFixture]
public class RecurringHostedServiceBaseTests
{
[TestCase("30 12 * * *", 30)]
[TestCase("15 18 * * *", (60 * 6) + 15)]
[TestCase("0 3 * * *", 60 * 15)]
[TestCase("0 3 2 * *", (24 * 60 * 1) + (60 * 15))]
[TestCase("0 6 * * 3", (24 * 60 * 3) + (60 * 18))]
public void Returns_Notification_Delay_From_Provided_Time(string firstRunTime, int expectedDelayInMinutes)
[TestCase(10_000, 3_000, 7_000, Description = "Subtracts elapsed time from period")]
[TestCase(10_000, 15_000, 0, Description = "Returns zero when execution exceeds period")]
[TestCase(10_000, 0, 10_000, Description = "Returns full period when elapsed is zero")]
[TestCase(10_000, 10_000, 0, Description = "Returns zero when execution equals period")]
[TestCase(-1, 1_000, 0, Description = "Returns zero for negative period")]
public void ComputeNextDelay_Returns_Expected_Result(long periodMs, long elapsedMs, long expectedMs)
{
var cronTabParser = new NCronTabParser();
var logger = Mock.Of<ILogger>();
var now = new DateTime(2020, 10, 31, 12, 0, 0);
var result = RecurringHostedServiceBase.GetDelay(firstRunTime, cronTabParser, logger, now, TimeSpan.Zero);
Assert.AreEqual(expectedDelayInMinutes, result.TotalMinutes);
}
[Test]
public void Returns_Notification_Delay_From_Default_When_Provided_Time_Too_Close_To_Current_Time()
{
var firstRunTime = "30 12 * * *";
var cronTabParser = new NCronTabParser();
var logger = Mock.Of<ILogger>();
var now = new DateTime(2020, 10, 31, 12, 25, 0);
var defaultDelay = TimeSpan.FromMinutes(10);
var result = RecurringHostedServiceBase.GetDelay(firstRunTime, cronTabParser, logger, now, defaultDelay);
Assert.AreEqual(defaultDelay.TotalMinutes, result.TotalMinutes);
}
[Test]
public void Logs_And_Returns_Notification_Delay_From_Default_When_Provided_Time_Is_Not_Valid()
{
var firstRunTime = "invalid";
var cronTabParser = new NCronTabParser();
var logger = new Mock<ILogger>();
var now = new DateTime(2020, 10, 31, 12, 25, 0);
var defaultDelay = TimeSpan.FromMinutes(10);
var result = RecurringHostedServiceBase.GetDelay(firstRunTime, cronTabParser, logger.Object, now, defaultDelay);
Assert.AreEqual(defaultDelay, result);
logger.Verify(
logger => logger.Log(
It.Is<LogLevel>(y => y == LogLevel.Warning),
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception, string>>()),
Times.Once);
}
[Test]
public void ComputeNextDelay_Subtracts_Elapsed_Time_From_Period()
{
var period = TimeSpan.FromSeconds(10);
var elapsed = TimeSpan.FromSeconds(3);
var period = TimeSpan.FromMilliseconds(periodMs);
var elapsed = TimeSpan.FromMilliseconds(elapsedMs);
TimeSpan result = RecurringHostedServiceBase.ComputeNextDelay(period, elapsed);
Assert.AreEqual(TimeSpan.FromSeconds(7), result);
Assert.AreEqual(TimeSpan.FromMilliseconds(expectedMs), result);
}
[Test]
public void ComputeNextDelay_Returns_Zero_When_Execution_Exceeds_Period()
public async Task Loop_Executes_Periodically_And_Respects_Cancellation()
{
var period = TimeSpan.FromSeconds(10);
var elapsed = TimeSpan.FromSeconds(15);
var executionCount = 0;
var timeProvider = new FakeTimeProvider();
using var executed = new SemaphoreSlim(0);
var sut = new TestRecurringHostedService(
period: TimeSpan.FromMinutes(5),
delay: TimeSpan.Zero,
timeProvider: timeProvider,
onExecute: _ => { Interlocked.Increment(ref executionCount); executed.Release(); return Task.CompletedTask; });
TimeSpan result = RecurringHostedServiceBase.ComputeNextDelay(period, elapsed);
using var cts = new CancellationTokenSource();
await sut.StartAsync(cts.Token);
Assert.AreEqual(TimeSpan.Zero, result);
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)), "First execution should complete");
Assert.AreEqual(1, executionCount);
timeProvider.Advance(TimeSpan.FromMinutes(5));
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)), "Second execution should complete");
Assert.AreEqual(2, executionCount);
timeProvider.Advance(TimeSpan.FromMinutes(5));
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)), "Third execution should complete");
Assert.AreEqual(3, executionCount);
cts.Cancel();
await sut.StopAsync(CancellationToken.None);
}
[Test]
public void ComputeNextDelay_Returns_Full_Period_When_Elapsed_Is_Zero()
public async Task TriggerExecution_Causes_Immediate_Execution()
{
var period = TimeSpan.FromSeconds(10);
var elapsed = TimeSpan.Zero;
var executionCount = 0;
var timeProvider = new FakeTimeProvider();
using var executed = new SemaphoreSlim(0);
var sut = new TestRecurringHostedService(
period: TimeSpan.FromHours(1),
delay: TimeSpan.Zero,
timeProvider: timeProvider,
onExecute: _ => { Interlocked.Increment(ref executionCount); executed.Release(); return Task.CompletedTask; });
TimeSpan result = RecurringHostedServiceBase.ComputeNextDelay(period, elapsed);
using var cts = new CancellationTokenSource();
await sut.StartAsync(cts.Token);
Assert.AreEqual(period, result);
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)), "First execution should complete");
Assert.AreEqual(1, executionCount, "Should have executed once initially");
sut.PublicTriggerExecution();
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)), "Triggered execution should complete");
Assert.AreEqual(2, executionCount, "Should have executed again after trigger");
cts.Cancel();
await sut.StopAsync(CancellationToken.None);
}
[Test]
public void ComputeNextDelay_Returns_Zero_When_Execution_Equals_Period()
public async Task TriggerExecution_Reset_Starts_New_Full_Period()
{
var period = TimeSpan.FromSeconds(10);
var elapsed = TimeSpan.FromSeconds(10);
var executionCount = 0;
var timeProvider = new FakeTimeProvider();
using var executed = new SemaphoreSlim(0);
var sut = new TestRecurringHostedService(
period: TimeSpan.FromHours(1),
delay: TimeSpan.Zero,
timeProvider: timeProvider,
onExecute: _ => { Interlocked.Increment(ref executionCount); executed.Release(); return Task.CompletedTask; });
TimeSpan result = RecurringHostedServiceBase.ComputeNextDelay(period, elapsed);
using var cts = new CancellationTokenSource();
await sut.StartAsync(cts.Token);
Assert.AreEqual(TimeSpan.Zero, result);
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.AreEqual(1, executionCount);
// Advance 30min into the 1h period, then trigger with Reset
timeProvider.Advance(TimeSpan.FromMinutes(30));
sut.PublicTriggerExecutionReset();
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)), "Triggered execution should complete");
Assert.AreEqual(2, executionCount);
// Reset means full period from now. Advancing 59min should not trigger.
timeProvider.Advance(TimeSpan.FromMinutes(59));
Assert.IsFalse(await executed.WaitAsync(TimeSpan.FromMilliseconds(50)), "Should not execute before full period");
// Advancing 1 more minute completes the period
timeProvider.Advance(TimeSpan.FromMinutes(1));
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.AreEqual(3, executionCount, "Should execute after full period");
cts.Cancel();
await sut.StopAsync(CancellationToken.None);
}
[Test]
public void ComputeNextDelay_Returns_Zero_For_Negative_Period()
public async Task TriggerExecution_None_Resumes_Original_Wait()
{
var period = TimeSpan.FromMilliseconds(-1);
var elapsed = TimeSpan.FromSeconds(1);
var executionCount = 0;
var timeProvider = new FakeTimeProvider();
using var executed = new SemaphoreSlim(0);
var sut = new TestRecurringHostedService(
period: TimeSpan.FromHours(1),
delay: TimeSpan.Zero,
timeProvider: timeProvider,
onExecute: _ => { Interlocked.Increment(ref executionCount); executed.Release(); return Task.CompletedTask; });
TimeSpan result = RecurringHostedServiceBase.ComputeNextDelay(period, elapsed);
using var cts = new CancellationTokenSource();
await sut.StartAsync(cts.Token);
Assert.AreEqual(TimeSpan.Zero, result);
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.AreEqual(1, executionCount);
// Advance 20min into 1h period, then trigger with None
timeProvider.Advance(TimeSpan.FromMinutes(20));
sut.PublicTriggerExecutionNone();
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)), "Triggered execution should complete");
Assert.AreEqual(2, executionCount);
// None means resume original schedule. Remaining is ~40min. Advancing 39min should not trigger.
timeProvider.Advance(TimeSpan.FromMinutes(39));
Assert.IsFalse(await executed.WaitAsync(TimeSpan.FromMilliseconds(50)), "Should not execute before original schedule");
// Advancing 1 more minute reaches the original tick
timeProvider.Advance(TimeSpan.FromMinutes(1));
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.AreEqual(3, executionCount, "Should execute at original scheduled time");
cts.Cancel();
await sut.StopAsync(CancellationToken.None);
}
[Test]
public async Task TriggerExecution_None_Skips_Overshot_Execution()
{
var executionCount = 0;
var timeProvider = new FakeTimeProvider();
using var executed = new SemaphoreSlim(0);
var sut = new TestRecurringHostedService(
period: TimeSpan.FromHours(1),
delay: TimeSpan.Zero,
timeProvider: timeProvider,
onExecute: _ =>
{
var count = Interlocked.Increment(ref executionCount);
if (count == 2)
{
// Simulate a triggered execution that takes longer than the remaining time
timeProvider.Advance(TimeSpan.FromMinutes(50));
}
executed.Release();
return Task.CompletedTask;
});
using var cts = new CancellationTokenSource();
await sut.StartAsync(cts.Token);
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.AreEqual(1, executionCount);
// Advance 20min, then trigger with None. Remaining is 40min.
// The triggered execution will advance time by 50min (overshooting by 10min).
timeProvider.Advance(TimeSpan.FromMinutes(20));
sut.PublicTriggerExecutionNone();
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.AreEqual(2, executionCount);
// The overshoot should skip the immediate tick. Next tick is at original + period = 2h from start.
// We're now at ~70min. Advancing 49min (to ~119min) should not trigger.
timeProvider.Advance(TimeSpan.FromMinutes(49));
Assert.IsFalse(await executed.WaitAsync(TimeSpan.FromMilliseconds(50)), "Should not execute — overshot tick was skipped");
// Advancing 1 more minute reaches the next period tick
timeProvider.Advance(TimeSpan.FromMinutes(1));
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.AreEqual(3, executionCount, "Should execute at next period tick");
cts.Cancel();
await sut.StopAsync(CancellationToken.None);
}
[Test]
public async Task TriggerExecution_Replace_Skips_Next_Tick()
{
var executionCount = 0;
var timeProvider = new FakeTimeProvider();
using var executed = new SemaphoreSlim(0);
var sut = new TestRecurringHostedService(
period: TimeSpan.FromHours(1),
delay: TimeSpan.Zero,
timeProvider: timeProvider,
onExecute: _ => { Interlocked.Increment(ref executionCount); executed.Release(); return Task.CompletedTask; });
using var cts = new CancellationTokenSource();
await sut.StartAsync(cts.Token);
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.AreEqual(1, executionCount);
// Advance 20min into 1h period, then trigger with Replace.
// Remaining is ~40min. Next execution at remaining + period = ~40min + 1h = ~100min from now.
timeProvider.Advance(TimeSpan.FromMinutes(20));
sut.PublicTriggerExecutionReplace();
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)), "Triggered execution should complete");
Assert.AreEqual(2, executionCount);
// The original next tick at 40min should be skipped. Advance to 60min — past the skipped tick.
timeProvider.Advance(TimeSpan.FromMinutes(60));
Assert.IsFalse(await executed.WaitAsync(TimeSpan.FromMilliseconds(50)), "Should not execute — skipped scheduled tick");
// Advance to the tick after the skipped one (~100min from trigger, ~40min more)
timeProvider.Advance(TimeSpan.FromMinutes(40));
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.AreEqual(3, executionCount, "Should execute at tick after the skipped one");
cts.Cancel();
await sut.StopAsync(CancellationToken.None);
}
[Test]
public async Task TriggerExecution_CustomDelay_Uses_Specified_Delay()
{
var executionCount = 0;
var timeProvider = new FakeTimeProvider();
using var executed = new SemaphoreSlim(0);
var sut = new TestRecurringHostedService(
period: TimeSpan.FromHours(1),
delay: TimeSpan.Zero,
timeProvider: timeProvider,
onExecute: _ => { Interlocked.Increment(ref executionCount); executed.Release(); return Task.CompletedTask; });
using var cts = new CancellationTokenSource();
await sut.StartAsync(cts.Token);
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.AreEqual(1, executionCount);
// Trigger with a custom 10-minute delay
sut.PublicTriggerExecutionWithDelay(TimeSpan.FromMinutes(10));
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)), "Triggered execution should complete");
Assert.AreEqual(2, executionCount);
// After the triggered execution, next should come after the custom 10min delay
timeProvider.Advance(TimeSpan.FromMinutes(9));
Assert.IsFalse(await executed.WaitAsync(TimeSpan.FromMilliseconds(50)), "Should not execute before custom delay");
timeProvider.Advance(TimeSpan.FromMinutes(1));
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.AreEqual(3, executionCount, "Should execute after custom delay");
cts.Cancel();
await sut.StopAsync(CancellationToken.None);
}
[Test]
public async Task TriggerExecution_During_InitialDelay_Does_Not_Leak_Strategy()
{
var executionCount = 0;
var timeProvider = new FakeTimeProvider();
using var executed = new SemaphoreSlim(0);
var sut = new TestRecurringHostedService(
period: TimeSpan.FromHours(1),
delay: TimeSpan.FromMinutes(30),
timeProvider: timeProvider,
onExecute: _ => { Interlocked.Increment(ref executionCount); executed.Release(); return Task.CompletedTask; });
using var cts = new CancellationTokenSource();
await sut.StartAsync(cts.Token);
// Still in initial delay — no execution yet
Assert.IsFalse(await executed.WaitAsync(TimeSpan.FromMilliseconds(50)), "Should not have executed during initial delay");
// Trigger with a custom delay during the initial delay
sut.PublicTriggerExecutionWithDelay(TimeSpan.FromMinutes(5));
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.AreEqual(1, executionCount, "Should have executed once after trigger interrupted delay");
// The custom delay should NOT be applied after the first execution —
// it was consumed when the initial delay was interrupted.
// Next wait should use the normal 1h period.
timeProvider.Advance(TimeSpan.FromMinutes(59));
Assert.IsFalse(await executed.WaitAsync(TimeSpan.FromMilliseconds(50)), "Custom delay should not leak — next wait uses normal period");
timeProvider.Advance(TimeSpan.FromMinutes(1));
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.AreEqual(2, executionCount, "Should execute after normal period");
cts.Cancel();
await sut.StopAsync(CancellationToken.None);
}
[Test]
public async Task Exception_In_PerformExecuteAsync_Does_Not_Kill_Loop()
{
var executionCount = 0;
var timeProvider = new FakeTimeProvider();
using var executed = new SemaphoreSlim(0);
var sut = new TestRecurringHostedService(
period: TimeSpan.FromMinutes(5),
delay: TimeSpan.Zero,
timeProvider: timeProvider,
onExecute: _ =>
{
var count = Interlocked.Increment(ref executionCount);
if (count == 1)
{
// Advance past the period so the next execution fires immediately
// after the loop catches the exception (no wait needed).
timeProvider.Advance(TimeSpan.FromMinutes(5));
throw new InvalidOperationException("Test exception");
}
executed.Release();
return Task.CompletedTask;
});
using var cts = new CancellationTokenSource();
await sut.StartAsync(cts.Token);
// The first execution throws (after advancing time), so the loop immediately retries.
// The second execution succeeds and signals the semaphore.
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)), "Second execution should complete despite first throwing");
Assert.AreEqual(2, executionCount, "Loop should continue after exception");
cts.Cancel();
await sut.StopAsync(CancellationToken.None);
}
[Test]
public async Task ChangePeriod_Takes_Effect_Immediately()
{
var executionCount = 0;
var timeProvider = new FakeTimeProvider();
using var executed = new SemaphoreSlim(0);
var sut = new TestRecurringHostedService(
period: TimeSpan.FromMinutes(10),
delay: TimeSpan.Zero,
timeProvider: timeProvider,
onExecute: _ => { Interlocked.Increment(ref executionCount); executed.Release(); return Task.CompletedTask; });
using var cts = new CancellationTokenSource();
await sut.StartAsync(cts.Token);
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.AreEqual(1, executionCount);
// Change to a 1-hour period — should interrupt the in-flight wait immediately.
sut.PublicChangePeriod(TimeSpan.FromHours(1));
// Advancing the old 10min period should NOT trigger execution.
timeProvider.Advance(TimeSpan.FromMinutes(10));
Assert.AreEqual(1, executionCount, "Should not execute at old period interval");
// Advancing to 1h total from first execution should trigger.
timeProvider.Advance(TimeSpan.FromMinutes(50));
Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.AreEqual(2, executionCount, "Should execute after new period");
cts.Cancel();
await sut.StopAsync(CancellationToken.None);
}
/// <summary>
/// A concrete test subclass that overrides the new PerformExecuteAsync(CancellationToken).
/// </summary>
private class TestRecurringHostedService : RecurringHostedServiceBase
{
private readonly Func<CancellationToken, Task> _onExecute;
public TestRecurringHostedService(TimeSpan period, TimeSpan delay, TimeProvider timeProvider, Func<CancellationToken, Task> onExecute)
: base(null, period, delay, timeProvider)
{
_onExecute = onExecute;
}
public override Task PerformExecuteAsync(CancellationToken stoppingToken)
=> _onExecute(stoppingToken);
public void PublicTriggerExecution() => TriggerExecution();
public void PublicTriggerExecutionReset() => TriggerExecution(NextExecutionStrategy.Reset);
public void PublicTriggerExecutionNone() => TriggerExecution(NextExecutionStrategy.None);
public void PublicTriggerExecutionReplace() => TriggerExecution(NextExecutionStrategy.Replace);
public void PublicTriggerExecutionWithDelay(TimeSpan nextDelay) => TriggerExecution(nextDelay);
public void PublicChangePeriod(TimeSpan newPeriod) => ChangePeriod(newPeriod);
}
}
@@ -27,6 +27,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="System.Data.Odbc" />