diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/DelayCalculator.cs b/src/Umbraco.Infrastructure/BackgroundJobs/DelayCalculator.cs index e096284dd851..cba99ffdc6b3 100644 --- a/src/Umbraco.Infrastructure/BackgroundJobs/DelayCalculator.cs +++ b/src/Umbraco.Infrastructure/BackgroundJobs/DelayCalculator.cs @@ -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 { /// - /// 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 for the current time. /// /// The configured time to first run the task in crontab format. - /// An instance of + /// An instance of . /// The logger. + /// The time provider used to determine the current time. /// The default delay to use when a first run time is not configured. - /// The delay before first running the recurring task. - public static TimeSpan GetDelay( - string firstRunTime, - ICronTabParser cronTabParser, - ILogger logger, - TimeSpan defaultDelay) => GetDelay(firstRunTime, cronTabParser, logger, DateTime.Now, defaultDelay); + /// + /// The delay before first running the recurring task. + /// + public static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, TimeProvider timeProvider, TimeSpan defaultDelay) + => GetDelay(firstRunTime, cronTabParser, logger, timeProvider.GetLocalNow().DateTime, defaultDelay); /// - /// 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. /// /// The configured time to first run the task in crontab format. - /// An instance of + /// An instance of . + /// The logger. + /// The default delay to use when a first run time is not configured. + /// + /// The delay before first running the recurring task. + /// + [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); + + /// + /// 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. + /// + /// The configured time to first run the task in crontab format. + /// An instance of . /// The logger. /// The current datetime. /// The default delay to use when a first run time is not configured. - /// The delay before first running the recurring task. - /// Internal to expose for unit tests. - internal static TimeSpan GetDelay( - string firstRunTime, - ICronTabParser cronTabParser, - ILogger logger, - DateTime now, - TimeSpan defaultDelay) + /// + /// The delay before first running the recurring task. + /// + /// + /// Internal to expose for unit tests. + /// + 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 @@ internal static TimeSpan GetDelay( 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; diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/IRecurringBackgroundJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/IRecurringBackgroundJob.cs index ca748629bbc1..969d1dc2e8cb 100644 --- a/src/Umbraco.Infrastructure/BackgroundJobs/IRecurringBackgroundJob.cs +++ b/src/Umbraco.Infrastructure/BackgroundJobs/IRecurringBackgroundJob.cs @@ -1,38 +1,89 @@ -using Umbraco.Cms.Core; using Umbraco.Cms.Core.Sync; namespace Umbraco.Cms.Infrastructure.BackgroundJobs; /// -/// A recurring background job +/// A recurring background job. /// public interface IRecurringBackgroundJob { - static readonly TimeSpan DefaultDelay = System.TimeSpan.FromMinutes(3); - static readonly ServerRole[] DefaultServerRoles = new[] { ServerRole.Single, ServerRole.SchedulingPublisher }; + /// + /// The default delay to use for recurring tasks for the first run after application start-up if no alternative is configured. + /// + [Obsolete("Use RecurringBackgroundJobBase.DefaultDelay instead. Scheduled for removal in Umbraco 19.")] + static readonly TimeSpan DefaultDelay = RecurringBackgroundJobBase.DefaultDelay; + + /// + /// The default server roles that recurring background jobs run on. + /// + [Obsolete("Use RecurringBackgroundJobBase.DefaultServerRoles instead. Scheduled for removal in Umbraco 19.")] + static readonly ServerRole[] DefaultServerRoles = RecurringBackgroundJobBase.DefaultServerRoles; /// /// Timespan representing how often the task should recur. /// + /// + /// The period. + /// + /// + /// Set to to (temporarily) disable automatic scheduling and turn the job into a manually triggered one (via ). Raise to switch back to a finite period at runtime. + /// TimeSpan Period { get; } /// - /// 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. + /// + /// + /// The delay. + /// + /// + /// Set to to skip the automatic first run entirely; the first execution then only occurs when manually triggered via . + /// + TimeSpan Delay => RecurringBackgroundJobBase.DefaultDelay; // TODO (V19): Remove the default implementation + + /// + /// Timespan to wait before re-evaluating execution conditions when an execution is ignored (e.g. runtime not ready, wrong server role or not main domain). /// - TimeSpan Delay { get => DefaultDelay; } + /// + /// The ignored delay. + /// + /// + /// This back-off prevents tight looping when is short (or ) and an execution is skipped without invoking . + /// Set to to disable the job for the remaining application lifecycle once an ignored condition is encountered — useful when the condition is known not to change (e.g. a server role that will not be promoted on this instance). + /// + TimeSpan IgnoredDelay => RecurringBackgroundJobBase.DefaultIgnoredDelay; // TODO (V19): Remove the default implementation /// - /// Gets the server roles for which this recurring background job is intended. + /// Gets the server roles the task executes on. /// - ServerRole[] ServerRoles { get => DefaultServerRoles; } + /// + /// The server roles. + /// + ServerRole[] ServerRoles => RecurringBackgroundJobBase.DefaultServerRoles; // TODO (V19): Remove the default implementation + /// + /// This event should be raised when the property changes to notify the background job manager to update the schedule for this job. + /// event EventHandler PeriodChanged; /// - /// Executes the logic associated with the recurring background job asynchronously. + /// Runs the background job. /// - /// A that represents the asynchronous execution of the background job. + /// + /// A task representing the asynchronous operation. + /// + [Obsolete("Use RunJobAsync(CancellationToken) instead. Scheduled for removal in Umbraco 19.")] Task RunJobAsync(); -} + /// + /// Runs the background job with cancellation support. + /// + /// A cancellation token that is signaled when the host is shutting down. + /// + /// A task representing the asynchronous operation. + /// + 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 +} diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/IRecurringBackgroundJobTrigger.cs b/src/Umbraco.Infrastructure/BackgroundJobs/IRecurringBackgroundJobTrigger.cs new file mode 100644 index 000000000000..2074c69d9e84 --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/IRecurringBackgroundJobTrigger.cs @@ -0,0 +1,45 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Infrastructure.HostedServices; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.BackgroundJobs; + +/// +/// Provides methods to signal a specific recurring background job to execute immediately. +/// +/// The type of the recurring background job to trigger, as registered via . +public interface IRecurringBackgroundJobTrigger + where TJob : class, ITriggerableRecurringBackgroundJob +{ + /// + /// Signals the background loop to execute immediately. + /// After the triggered execution, the original schedule is kept. + /// + /// + /// true if the job was found and triggered; false if no hosted service is running for this job type. + /// + /// + bool TriggerExecution(); + + /// + /// Signals the background loop to execute immediately, with the specified strategy for determining the next execution after the triggered one completes. + /// + /// Controls the delay after the triggered execution. + /// + /// true if the job was found and triggered; false if no hosted service is running for this job type. + /// + bool TriggerExecution(NextExecutionStrategy strategy); + + /// + /// 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). + /// + /// The target interval from execution start to the next execution. Execution time is subtracted to prevent drift. + /// + /// true if the job was found and triggered; false if no hosted service is running for this job type. + /// + bool TriggerExecution(TimeSpan nextDelay); +} diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/ITriggerableRecurringBackgroundJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/ITriggerableRecurringBackgroundJob.cs new file mode 100644 index 000000000000..035975d03103 --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/ITriggerableRecurringBackgroundJob.cs @@ -0,0 +1,11 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +namespace Umbraco.Cms.Infrastructure.BackgroundJobs; + +/// +/// Marker interface for recurring background jobs that support being triggered manually. +/// Only jobs implementing this interface can be triggered via . +/// +public interface ITriggerableRecurringBackgroundJob : IRecurringBackgroundJob +{ } diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ReportSiteJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ReportSiteJob.cs index 3a3bd980597d..17f75db9edf8 100644 --- a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ReportSiteJob.cs +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ReportSiteJob.cs @@ -1,7 +1,5 @@ using System.Text; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Sync; using Umbraco.Cms.Core.Telemetry; @@ -12,33 +10,23 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; /// /// Represents a background job that collects and reports information about the current Umbraco site, typically for analytics, diagnostics, or telemetry purposes. /// -public class ReportSiteJob : IRecurringBackgroundJob +public class ReportSiteJob : RecurringBackgroundJobBase { /// /// Gets the period at which the report site job runs. /// - public TimeSpan Period => TimeSpan.FromDays(1); + public override TimeSpan Period => TimeSpan.FromDays(1); /// /// Gets the time interval to wait between executions of the . /// The delay is set to 5 minutes. /// - public TimeSpan Delay => TimeSpan.FromMinutes(5); + public override TimeSpan Delay => TimeSpan.FromMinutes(5); /// /// Gets an array containing all possible values of the enumeration. /// - public ServerRole[] ServerRoles => Enum.GetValues(); - - /// - /// Event that is triggered when the reporting period for the site job is changed. - /// - /// No-op event as the period never changes on this job - public event EventHandler PeriodChanged - { - add { } - remove { } - } + public override ServerRole[] ServerRoles => Enum.GetValues(); private readonly ILogger _logger; private readonly ITelemetryService _telemetryService; @@ -67,8 +55,11 @@ public ReportSiteJob( /// /// Executes the background job that sends the anonymous site ID to the telemetry service. /// - /// A task that represents the asynchronous operation. - public async Task RunJobAsync() + /// A cancellation token that is signaled when the host is shutting down. + /// + /// A task that represents the asynchronous operation. + /// + public override async Task RunJobAsync(CancellationToken cancellationToken) { TelemetryReportData? telemetryReportData = await _telemetryService.GetTelemetryReportDataAsync().ConfigureAwait(false); if (telemetryReportData is null) @@ -100,7 +91,7 @@ public async Task RunJobAsync() // Make a HTTP Post to telemetry service // https://telemetry.umbraco.com/installs/ // Fire & Forget, do not need to know if its a 200, 500 etc - using (await httpClient.SendAsync(request)) + using (await httpClient.SendAsync(request, cancellationToken)) { } } catch diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/InstructionProcessJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/InstructionProcessJob.cs index fb20a47b09b8..7eeba8597810 100644 --- a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/InstructionProcessJob.cs +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/InstructionProcessJob.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Sync; @@ -13,28 +12,24 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration; /// /// Implements periodic database instruction processing as a hosted service. /// -public class InstructionProcessJob : IRecurringBackgroundJob +public class InstructionProcessJob : RecurringBackgroundJobBase { + private readonly TimeSpan _period; + /// /// Gets the interval between executions of the instruction process job. /// - public TimeSpan Period { get; } + public override TimeSpan Period => _period; /// /// Gets the delay time before the job is executed. The delay is fixed at one minute. /// - public TimeSpan Delay { get => TimeSpan.FromMinutes(1); } + public override TimeSpan Delay => TimeSpan.FromMinutes(1); /// /// Gets an array containing all possible values of the enumeration. /// - public ServerRole[] ServerRoles { get => Enum.GetValues(); } - - /// - /// Event that is raised when the execution period of the is changed. - /// - /// No-op event as the period never changes on this job - public event EventHandler PeriodChanged { add { } remove { } } + public override ServerRole[] ServerRoles => Enum.GetValues(); private readonly ILogger _logger; private readonly IServerMessenger _messenger; @@ -53,15 +48,18 @@ public InstructionProcessJob( _messenger = messenger; _logger = logger; - Period = globalSettings.Value.DatabaseServerMessenger.TimeBetweenSyncOperations; + _period = globalSettings.Value.DatabaseServerMessenger.TimeBetweenSyncOperations; } /// /// Executes the instruction processing job asynchronously by synchronizing messages using the messenger service. /// Logs an error if the synchronization fails, but always completes the task. /// - /// A completed task representing the asynchronous operation. - public Task RunJobAsync() + /// A cancellation token that is signaled when the host is shutting down. + /// + /// A completed task representing the asynchronous operation. + /// + public override Task RunJobAsync(CancellationToken cancellationToken) { try { diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/TouchServerJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/TouchServerJob.cs index b90ab4f6777d..cd2a5a02097f 100644 --- a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/TouchServerJob.cs +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/TouchServerJob.cs @@ -15,37 +15,38 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration; /// /// Implements periodic server "touching" (to mark as active/deactive) as a hosted service. /// -public class TouchServerJob : IRecurringBackgroundJob +public class TouchServerJob : RecurringBackgroundJobBase { + private TimeSpan _period; + /// /// Gets the period that defines how often the server should be touched. /// - public TimeSpan Period { get; private set; } + public override TimeSpan Period => _period; /// /// Gets the fixed delay interval of 15 seconds between executions of the touch server job. /// This interval determines how often the server registration is updated. /// - public TimeSpan Delay { get => TimeSpan.FromSeconds(15); } + public override TimeSpan Delay => TimeSpan.FromSeconds(15); /// /// Gets all server roles on which this job runs. This property returns every possible value, indicating the job runs on all server roles. /// /// Runs on all servers - public ServerRole[] ServerRoles { get => Enum.GetValues(); } + public override ServerRole[] ServerRoles => Enum.GetValues(); private event EventHandler? _periodChanged; /// /// Occurs when the period of the TouchServerJob changes. /// - public event EventHandler PeriodChanged + public override event EventHandler PeriodChanged { add { _periodChanged += value; } remove { _periodChanged -= value; } } - private readonly IHostingEnvironment _hostingEnvironment; private readonly ILogger _logger; private readonly IServerRegistrationService _serverRegistrationService; @@ -67,18 +68,17 @@ public TouchServerJob( IOptionsMonitor globalSettings, IServerRoleAccessor serverRoleAccessor) { - _serverRegistrationService = serverRegistrationService ?? - throw new ArgumentNullException(nameof(serverRegistrationService)); + _serverRegistrationService = serverRegistrationService ?? throw new ArgumentNullException(nameof(serverRegistrationService)); _hostingEnvironment = hostingEnvironment; _logger = logger; _globalSettings = globalSettings.CurrentValue; _serverRoleAccessor = serverRoleAccessor; - Period = _globalSettings.DatabaseServerRegistrar.WaitTimeBetweenCalls; + _period = _globalSettings.DatabaseServerRegistrar.WaitTimeBetweenCalls; globalSettings.OnChange(x => { _globalSettings = x; - Period = x.DatabaseServerRegistrar.WaitTimeBetweenCalls; + _period = x.DatabaseServerRegistrar.WaitTimeBetweenCalls; _periodChanged?.Invoke(this, EventArgs.Empty); }); @@ -88,8 +88,11 @@ public TouchServerJob( /// Executes the job that updates the server registration by touching the server record in the database. /// This keeps the server's registration active and ensures its status remains current. /// - /// A completed task when the job has finished running. - public Task RunJobAsync() + /// A cancellation token that is signaled when the host is shutting down. + /// + /// A completed task when the job has finished running. + /// + public override Task RunJobAsync(CancellationToken cancellationToken) { // If the IServerRoleAccessor has been changed away from ElectedServerRoleAccessor this task no longer makes sense, diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/TempFileCleanupJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/TempFileCleanupJob.cs index fcccd79be6d2..47ea61f5af51 100644 --- a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/TempFileCleanupJob.cs +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/TempFileCleanupJob.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.IO; -using Umbraco.Cms.Core.Runtime; using Umbraco.Cms.Core.Sync; namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; @@ -15,24 +14,18 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; /// Will run on all servers - even though file upload should only be handled on the scheduling publisher, this will /// ensure that in the case it happens on subscribers that they are cleaned up too. /// -public class TempFileCleanupJob : IRecurringBackgroundJob +public class TempFileCleanupJob : RecurringBackgroundJobBase { /// /// Gets the time interval between each execution of the temporary file cleanup job. /// - public TimeSpan Period { get => TimeSpan.FromMinutes(60); } + public override TimeSpan Period => TimeSpan.FromMinutes(60); /// /// Gets the server roles on which this job runs. This job is configured to run on all server roles. /// /// Runs on all servers - public ServerRole[] ServerRoles { get => Enum.GetValues(); } - - /// - /// Occurs when the period of the TempFileCleanupJob changes. - /// - /// No-op event as the period never changes on this job - public event EventHandler PeriodChanged { add { } remove { } } + public override ServerRole[] ServerRoles => Enum.GetValues(); private readonly TimeSpan _age = TimeSpan.FromDays(1); private readonly IIOHelper _ioHelper; @@ -55,18 +48,23 @@ public TempFileCleanupJob(IIOHelper ioHelper, ILogger logger /// /// Asynchronously executes the cleanup of temporary files in the configured temporary folders. /// - /// A task that represents the asynchronous cleanup operation. - public Task RunJobAsync() + /// A cancellation token that is signaled when the host is shutting down. + /// + /// A task that represents the asynchronous cleanup operation. + /// + public override Task RunJobAsync(CancellationToken cancellationToken) { foreach (DirectoryInfo folder in _tempFolders) { - CleanupFolder(folder); + cancellationToken.ThrowIfCancellationRequested(); + + CleanupFolder(folder, cancellationToken); } return Task.CompletedTask; } - private void CleanupFolder(DirectoryInfo folder) + private void CleanupFolder(DirectoryInfo folder, CancellationToken cancellationToken) { CleanFolderResult result = _ioHelper.CleanFolder(folder, _age); switch (result.Status) @@ -96,6 +94,8 @@ private void CleanupFolder(DirectoryInfo folder) FileInfo[] files = folder.GetFiles("*.*", SearchOption.AllDirectories); foreach (FileInfo file in files) { + cancellationToken.ThrowIfCancellationRequested(); + if (DateTime.UtcNow - file.LastWriteTimeUtc > _age) { try @@ -110,5 +110,4 @@ private void CleanupFolder(DirectoryInfo folder) } } } - } diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobBase.cs b/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobBase.cs new file mode 100644 index 000000000000..1e88123a7c69 --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobBase.cs @@ -0,0 +1,58 @@ +using Umbraco.Cms.Core.Sync; + +namespace Umbraco.Cms.Infrastructure.BackgroundJobs; + +/// +/// Base class for recurring background jobs that provides default values for common properties. +/// +/// +/// Implementors only need to provide and . +/// +public abstract class RecurringBackgroundJobBase : IRecurringBackgroundJob +{ + /// + /// The default delay to use for recurring tasks for the first run after application start-up if no alternative is configured. + /// + /// + /// The default of 3 minutes is chosen to allow the application to finish starting up and stabilize before the first execution of recurring tasks. + /// + protected internal static readonly TimeSpan DefaultDelay = TimeSpan.FromMinutes(3); + + /// + /// The default back-off to use when an execution is ignored, before re-evaluating execution conditions. + /// + /// + /// The default of 1 minute prevents tight looping when an execution is skipped (e.g. runtime not ready, wrong server role or not main domain) and the configured is short or . + /// + protected internal static readonly TimeSpan DefaultIgnoredDelay = TimeSpan.FromMinutes(1); + + /// + /// The default server roles that recurring background jobs run on. + /// + /// + /// The default of running on both and is chosen to ensure recurring background jobs do not run on every server (in a load-balanced environment). + /// + protected internal static readonly ServerRole[] DefaultServerRoles = [ServerRole.Single, ServerRole.SchedulingPublisher]; + + /// + public abstract TimeSpan Period { get; } + + /// + public virtual TimeSpan Delay => DefaultDelay; + + /// + public virtual TimeSpan IgnoredDelay => DefaultIgnoredDelay; + + /// + public virtual ServerRole[] ServerRoles => DefaultServerRoles; + + /// + public virtual event EventHandler PeriodChanged { add { } remove { } } + + /// + [Obsolete("Use RunJobAsync(CancellationToken) instead. Scheduled for removal in Umbraco 19.")] + public Task RunJobAsync() => RunJobAsync(CancellationToken.None); + + /// + public abstract Task RunJobAsync(CancellationToken cancellationToken); +} diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedService.cs b/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedService.cs index 7ab222afcf44..0520bbf0d183 100644 --- a/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedService.cs +++ b/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedService.cs @@ -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,134 +22,220 @@ public static class RecurringBackgroundJobHostedService /// Creates a factory function that produces hosted services for recurring background jobs. /// /// The service provider used to create hosted service instances. - /// A function that takes an and returns an . - public static Func CreateHostedServiceFactory(IServiceProvider serviceProvider) => - (IRecurringBackgroundJob job) => + /// + /// A function that takes an and returns an . + /// + public static Func CreateHostedServiceFactory(IServiceProvider serviceProvider) + => (IRecurringBackgroundJob job) => { Type hostedServiceType = typeof(RecurringBackgroundJobHostedService<>).MakeGenericType(job.GetType()); + return (IHostedService)ActivatorUtilities.CreateInstance(serviceProvider, hostedServiceType, job); }; } /// /// Runs a recurring background job inside a hosted service. -/// Generic version for DependencyInjection /// -/// Type of the Job -public class RecurringBackgroundJobHostedService : RecurringHostedServiceBase where TJob : IRecurringBackgroundJob +/// The type of the job. +public class RecurringBackgroundJobHostedService : RecurringHostedServiceBase + where TJob : IRecurringBackgroundJob { - + private readonly IRuntimeState _runtimeState; private readonly ILogger> _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; + private readonly TimeProvider _timeProvider; /// - /// Initializes a new instance of the class, which manages the execution of a recurring background job. + /// Initializes a new instance of the class, which manages the execution of a recurring background job. /// /// Provides information about the current runtime state of the Umbraco application. /// The logger used to record diagnostic and operational information for this hosted service. /// The main domain instance responsible for coordinating single-instance operations across multiple application domains. /// Determines the current server's role in a multi-server environment. /// Handles the publishing and subscribing of application events. + /// The event messages factory. /// The recurring background job instance to be managed and executed by this service. + /// The time provider used for scheduling and elapsed time measurement. public RecurringBackgroundJobHostedService( IRuntimeState runtimeState, ILogger> logger, IMainDom mainDom, IServerRoleAccessor serverRoleAccessor, IEventAggregator eventAggregator, - TJob job) - : base(logger, job.Period, job.Delay) + 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; + _timeProvider = timeProvider; - _job.PeriodChanged += (sender, e) => ChangePeriod(_job.Period); + _job.PeriodChanged += OnPeriodChanged; } + /// + /// Initializes a new instance of the class, which manages the execution of a recurring background job. + /// + /// Provides information about the current runtime state of the Umbraco application. + /// The logger used to record diagnostic and operational information for this hosted service. + /// The main domain instance responsible for coordinating single-instance operations across multiple application domains. + /// Determines the current server's role in a multi-server environment. + /// Handles the publishing and subscribing of application events. + /// The recurring background job instance to be managed and executed by this service. + [Obsolete("Use the constructor accepting IEventMessagesFactory and TimeProvider instead. Scheduled for removal in Umbraco 19.")] + public RecurringBackgroundJobHostedService( + IRuntimeState runtimeState, + ILogger> logger, + IMainDom mainDom, + IServerRoleAccessor serverRoleAccessor, + IEventAggregator eventAggregator, + TJob job) + : this(runtimeState, logger, mainDom, serverRoleAccessor, eventAggregator, StaticServiceProvider.Instance.GetRequiredService(), job, TimeProvider.System) + { } + /// - 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 IgnoreAndWaitAsync("Job not running as runlevel not yet ready", eventMessages, executingNotification, stoppingToken); return; } // Don't run on replicas nor unknown role servers 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 IgnoreAndWaitAsync("Job not running on this server role", eventMessages, executingNotification, stoppingToken); return; } // Ensure we do not run if not main domain, but do NOT lock it if (!_mainDom.IsMainDom) { - _logger.LogDebug("Job not running as not MainDom"); - await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobIgnoredNotification(_job, new EventMessages()).WithStateFrom(executingNotification)); + await IgnoreAndWaitAsync("Job not running as not MainDom", eventMessages, 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); } - } - /// - /// Asynchronously starts the recurring background job and publishes notifications before and after the job is started. - /// This method first publishes a prior to starting the job, - /// then calls the base implementation to start the job, and finally publishes a . - /// - /// A token to monitor for cancellation requests. - /// A task that represents the asynchronous start operation. + /// + [Obsolete("Override PerformExecuteAsync(CancellationToken) instead. Scheduled for removal in Umbraco 19.")] + public override Task PerformExecuteAsync(object? state) => PerformExecuteAsync(CancellationToken.None); + + /// 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); + + // Suppress execution context flow around base.StartAsync so the fire-and-forget ExecuteAsync loop + // does not capture AsyncLocal state from the host — in particular Umbraco's static AmbientScopeStack, + // which uses a ConcurrentStack reference that, once non-null, would be shared across every + // hosted service that inherits this ExecutionContext. Without this, concurrent scope pushes/pops + // across recurring loops and other hosted services interleave and trigger "not the ambient scope" + // errors at Scope.Dispose (see DistributedJobService.EnsureJobsAsync for the original repro). + Task startTask; + using (ExecutionContext.IsFlowSuppressed() ? null : (IDisposable?)ExecutionContext.SuppressFlow()) + { + startTask = base.StartAsync(cancellationToken); + } - await base.StartAsync(cancellationToken); + await startTask; - await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobStartedNotification(_job, new EventMessages()).WithStateFrom(startingNotification)); + await _eventAggregator.PublishAsync(new RecurringBackgroundJobStartedNotification(_job, eventMessages).WithStateFrom(startingNotification), cancellationToken); + } + + /// + public override async Task StopAsync(CancellationToken cancellationToken) + { + EventMessages eventMessages = _eventMessagesFactory.Get(); + var stoppingNotification = new RecurringBackgroundJobStoppingNotification(_job, eventMessages); + await _eventAggregator.PublishAsync(stoppingNotification, cancellationToken); + + await base.StopAsync(cancellationToken); + + await _eventAggregator.PublishAsync(new RecurringBackgroundJobStoppedNotification(_job, eventMessages).WithStateFrom(stoppingNotification), cancellationToken); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _job.PeriodChanged -= OnPeriodChanged; + } + base.Dispose(disposing); } /// - /// Asynchronously stops the recurring background job service, publishing notifications before and after stopping. + /// Handles the event by updating the base class period. /// - /// A token to monitor for cancellation requests. - /// A task that represents the asynchronous stop operation. - public override async Task StopAsync(CancellationToken cancellationToken) + /// The sender. + /// The instance containing the event data. + private void OnPeriodChanged(object? sender, EventArgs e) + => ChangePeriod(_job.Period); + + /// + /// Publishes the ignored notification and waits for before allowing the next iteration, preventing tight looping when execution is skipped. + /// + /// The full debug message describing why the execution is ignored. + /// The event messages for the notification. + /// The originating executing notification to carry state from. + /// A cancellation token that is signaled when the host is shutting down. + private async Task IgnoreAndWaitAsync( + string message, + EventMessages eventMessages, + RecurringBackgroundJobExecutingNotification executingNotification, + CancellationToken stoppingToken) { - var stoppingNotification = new Notifications.RecurringBackgroundJobStoppingNotification(_job, new EventMessages()); - await _eventAggregator.PublishAsync(stoppingNotification); + _logger.LogDebug(message); + await _eventAggregator.PublishAsync(new RecurringBackgroundJobIgnoredNotification(_job, eventMessages).WithStateFrom(executingNotification), stoppingToken); - await base.StopAsync(cancellationToken); + TimeSpan ignoredDelay = _job.IgnoredDelay; + if (ignoredDelay == TimeSpan.Zero) + { + return; + } - await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobStoppedNotification(_job, new EventMessages()).WithStateFrom(stoppingNotification)); + try + { + await Task.Delay(ignoredDelay, _timeProvider, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Back-off interrupted by shutdown; the ignored notification has already been published, so do not also publish canceled. + } } } diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceRunner.cs b/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceRunner.cs index fbb0c1b5ba5f..4bb776852807 100644 --- a/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceRunner.cs +++ b/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceRunner.cs @@ -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; /// -/// 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. /// public class RecurringBackgroundJobHostedServiceRunner : IHostedService { private readonly ILogger _logger; private readonly List _jobs; private readonly Func _jobFactory; - private readonly List _hostedServices = new(); - + private readonly ConcurrentDictionary _hostedServices = new(); /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// An used for logging within the runner. - /// A collection of instances to be managed by the runner. - /// A factory function that creates an for each . + /// An used for logging within the runner. + /// A collection of instances to be managed by the runner. + /// A factory function that creates an for each . public RecurringBackgroundJobHostedServiceRunner( ILogger logger, IEnumerable jobs, @@ -30,80 +31,122 @@ public RecurringBackgroundJobHostedServiceRunner( _jobFactory = jobFactory; } + /// 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; + }); - await hostedService.StartAsync(cancellationToken).ConfigureAwait(false); + if (!added) + { + _logger.LogWarning("A background hosted service for {JobTypeName} is already registered, skipping duplicate", jobType.Name); + continue; + } - _hostedServices.Add(new NamedServiceJob(jobName, hostedService)); + _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); } - 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"); } - /// - /// Asynchronously stops all recurring background job hosted services managed by this runner. - /// - /// A that can be used to cancel the stop operation. - /// A representing the asynchronous stop operation. + /// 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 + /// + /// 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. + /// + /// The type of the recurring background job to trigger. + /// Controls the delay after the triggered execution. + /// + /// true if the job was found and triggered; false if no hosted service is running for this job type. + /// + internal bool TriggerExecution(NextExecutionStrategy strategy) + where TJob : ITriggerableRecurringBackgroundJob { - /// - /// Initializes a new instance of the class using the specified job name and hosted service instance. - /// - /// The unique name identifying the job. - /// The instance to be executed as the background job. - public NamedServiceJob(string name, IHostedService hostedService) + if (FindHostedService() is not { } hostedService) { - Name = name; - HostedService = hostedService; + return false; } - /// - /// Gets the unique name that identifies this background job. - /// - public string Name { get; } + hostedService.TriggerExecution(strategy); - /// - /// Gets the hosted service instance associated with the named service job. - /// - public IHostedService HostedService { get; } + return true; } + + /// + /// 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). + /// + /// The type of the recurring background job to trigger. + /// The target interval from execution start to the next execution. Execution time is subtracted to prevent drift. + /// + /// true if the job was found and triggered; false if no hosted service is running for this job type. + /// + internal bool TriggerExecution(TimeSpan nextDelay) + where TJob : ITriggerableRecurringBackgroundJob + { + if (FindHostedService() is not { } hostedService) + { + return false; + } + + hostedService.TriggerExecution(nextDelay); + + return true; + } + + private RecurringHostedServiceBase? FindHostedService() + where TJob : ITriggerableRecurringBackgroundJob + => _hostedServices.TryGetValue(typeof(TJob), out IHostedService? service) ? service as RecurringHostedServiceBase : null; } diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobTrigger.cs b/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobTrigger.cs new file mode 100644 index 000000000000..f98c1fe72b82 --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobTrigger.cs @@ -0,0 +1,35 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Infrastructure.HostedServices; + +namespace Umbraco.Cms.Infrastructure.BackgroundJobs; + +/// +/// Default implementation of that delegates to the hosted service runner. +/// +/// The type of the recurring background job to trigger. +internal sealed class RecurringBackgroundJobTrigger : IRecurringBackgroundJobTrigger + where TJob : class, ITriggerableRecurringBackgroundJob +{ + private readonly RecurringBackgroundJobHostedServiceRunner _runner; + + /// + /// Initializes a new instance of the class. + /// + /// The runner. + public RecurringBackgroundJobTrigger(RecurringBackgroundJobHostedServiceRunner runner) + => _runner = runner; + + /// + public bool TriggerExecution() + => TriggerExecution(NextExecutionStrategy.None); + + /// + public bool TriggerExecution(NextExecutionStrategy strategy) + => _runner.TriggerExecution(strategy); + + /// + public bool TriggerExecution(TimeSpan nextDelay) + => _runner.TriggerExecution(nextDelay); +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.BackgroundJobs.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.BackgroundJobs.cs index 0795f9cecfd3..0169dc0e0017 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.BackgroundJobs.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.BackgroundJobs.cs @@ -38,7 +38,9 @@ public static IUmbracoBuilder AddBackgroundJobs(this IUmbracoBuilder builder) builder.Services.AddHostedService(); builder.Services.AddSingleton(RecurringBackgroundJobHostedService.CreateHostedServiceFactory); - builder.Services.AddHostedService(); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(typeof(IRecurringBackgroundJobTrigger<>), typeof(RecurringBackgroundJobTrigger<>)); builder.Services.AddHostedService(); builder.AddNotificationAsyncHandler(); builder.AddNotificationAsyncHandler(); diff --git a/src/Umbraco.Infrastructure/Extensions/ServiceCollectionExtensions.cs b/src/Umbraco.Infrastructure/Extensions/ServiceCollectionExtensions.cs index 51bf02e0fef9..0c85b85d904a 100644 --- a/src/Umbraco.Infrastructure/Extensions/ServiceCollectionExtensions.cs +++ b/src/Umbraco.Infrastructure/Extensions/ServiceCollectionExtensions.cs @@ -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,22 @@ namespace Umbraco.Extensions; public static class ServiceCollectionExtensions { /// - /// Adds a recurring background job with an implementation type of - /// to the specified . + /// Adds a recurring background job with an implementation type of . /// /// The to add the recurring background job to. public static void AddRecurringBackgroundJob( this IServiceCollection services) - where TJob : class, IRecurringBackgroundJob => - services.AddSingleton(); + where TJob : class, IRecurringBackgroundJob + => services.AddSingleton(); /// - /// Adds a recurring background job with an implementation type of - /// using the factory - /// to the specified . + /// Adds a recurring background job with an implementation type of using the factory . /// /// The to add the recurring background job to. /// A factory function to create an instance of using the provided . public static void AddRecurringBackgroundJob( this IServiceCollection services, Func implementationFactory) - where TJob : class, IRecurringBackgroundJob => - services.AddSingleton(implementationFactory); - + where TJob : class, IRecurringBackgroundJob + => services.AddSingleton(implementationFactory); } - diff --git a/src/Umbraco.Infrastructure/HostedServices/NextExecutionStrategy.cs b/src/Umbraco.Infrastructure/HostedServices/NextExecutionStrategy.cs new file mode 100644 index 000000000000..08f461fd2efb --- /dev/null +++ b/src/Umbraco.Infrastructure/HostedServices/NextExecutionStrategy.cs @@ -0,0 +1,30 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +namespace Umbraco.Cms.Infrastructure.HostedServices; + +/// +/// Determines the next execution strategy after a manually triggered execution completes. +/// +public enum NextExecutionStrategy +{ + /// + /// 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. + /// + None, + + /// + /// Reset the period: wait a full period after the triggered execution completes. + /// The triggered execution effectively shifts the schedule forward. + /// + Reset, + + /// + /// 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. + /// + Replace, +} diff --git a/src/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBase.cs b/src/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBase.cs index e6d73c96ae72..6a7dd322ce02 100644 --- a/src/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBase.cs +++ b/src/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBase.cs @@ -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,268 @@ namespace Umbraco.Cms.Infrastructure.HostedServices; /// -/// Provides a base class for recurring background tasks implemented as hosted services. +/// Provides a base class for recurring background tasks implemented as hosted services. /// -/// -/// See: . -/// -public abstract class RecurringHostedServiceBase : IHostedService, IDisposable +public abstract class RecurringHostedServiceBase : BackgroundService { /// - /// 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. /// 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; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// Logger. - /// Timespan representing how often the task should recur. - /// - /// Timespan representing the initial delay after application start-up before the first run of the task - /// occurs. - /// - protected RecurringHostedServiceBase(ILogger? logger, TimeSpan period, TimeSpan delay) + /// Timespan representing how often the task should recur. Set to to disable automatic scheduling and only run when manually triggered via . + /// Timespan representing the initial delay after application start-up before the first run of the task occurs. Set to to skip the automatic first run; the first execution then only occurs when manually triggered via . + /// The time provider used for scheduling and elapsed time measurement. + protected RecurringHostedServiceBase(ILogger? logger, TimeSpan period, TimeSpan delay, TimeProvider timeProvider) { + if (period != Timeout.InfiniteTimeSpan) + { + ArgumentOutOfRangeException.ThrowIfLessThan(period, TimeSpan.Zero); + } + + if (delay != Timeout.InfiniteTimeSpan) + { + ArgumentOutOfRangeException.ThrowIfLessThan(delay, TimeSpan.Zero); + } + _logger = logger; - _period = period; + Interlocked.Exchange(ref _periodTicks, period.Ticks); _delay = delay; - } - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); + _timeProvider = timeProvider; } /// - /// 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 class. /// - /// The configured time to first run the task in crontab format. - /// An instance of - /// The logger. - /// The default delay to use when a first run time is not configured. - /// The delay before first running the recurring task. - protected static TimeSpan GetDelay( - string firstRunTime, - ICronTabParser cronTabParser, - ILogger logger, - TimeSpan defaultDelay) => GetDelay(firstRunTime, cronTabParser, logger, DateTime.Now, defaultDelay); + /// Logger. + /// Timespan representing how often the task should recur. + /// Timespan representing the initial delay after application start-up before the first run of the task occurs. + [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) + { } /// - /// 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. /// /// The configured time to first run the task in crontab format. - /// An instance of + /// An instance of . /// The logger. - /// The current datetime. /// The default delay to use when a first run time is not configured. - /// The delay before first running the recurring task. - /// Internal to expose for unit tests. - internal static TimeSpan GetDelay( - string firstRunTime, - ICronTabParser cronTabParser, - ILogger logger, - DateTime now, - TimeSpan defaultDelay) + /// + /// The delay before first running the recurring task. + /// + [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); + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - // If first run time not set, start with just small delay after application start. - if (string.IsNullOrEmpty(firstRunTime)) + // Initial delay (also interruptible via signal) + bool signaled = false; + if (_delay != TimeSpan.Zero) { - return defaultDelay; + try + { + // Do not cancel/signal the wait when the period changes during the initial delay + signaled = await WaitForSignalAsync(_delay, CancellationToken.None, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } } - // If first run time not a valid cron tab, log, and revert to small delay after application start. - if (!cronTabParser.IsValidCronTab(firstRunTime)) + // Honor a TriggerExecution(TimeSpan) issued during the initial delay on the first wait cycle. + // Strategy-only triggers (None/Reset/Replace) have no custom delay and collapse to the normal Period — + // there is no "next scheduled tick" yet for Replace to skip, and None/Reset reduce to "use Period" in this phase. + TimeSpan nextDelayBasis = ReadPeriod(); + if (signaled) { - logger.LogWarning("Could not parse {FirstRunTime} as a crontab expression. Defaulting to default delay for hosted service start.", firstRunTime); - return defaultDelay; + TriggerState initialTrigger = Interlocked.Exchange(ref _triggerState, TriggerState.Default); + if (initialTrigger.Delay.HasValue) + { + nextDelayBasis = initialTrigger.Delay.Value; + } } - // 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; + 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); + } } - /// - public virtual Task StartAsync(CancellationToken cancellationToken) + /// + /// Waits for the remaining period (minus execution time) before the next execution. + /// If is called, the wait exits immediately and returns the delay basis for the execution after the triggered one. + /// + /// The delay basis. + /// The execution elapsed. + /// The stopping token. + /// + /// The delay basis to use for the next wait cycle. + /// + private async Task WaitForNextExecutionAsync(TimeSpan delayBasis, TimeSpan executionElapsed, CancellationToken stoppingToken) { - using (!ExecutionContext.IsFlowSuppressed() ? (IDisposable)ExecutionContext.SuppressFlow() : null) + 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) { - _timer = new Timer(ExecuteAsync, null, _delay, _period); + delay = ComputeNextDelay(delayBasis + period, executionElapsed); } - return Task.CompletedTask; - } + if (delay == TimeSpan.Zero) + { + return period; + } - /// - public virtual Task StopAsync(CancellationToken cancellationToken) - { - _period = Timeout.InfiniteTimeSpan; - _timer?.Change(Timeout.Infinite, 0); - return Task.CompletedTask; + long waitStart = _timeProvider.GetTimestamp(); + + while (true) + { + CancellationToken periodChangeToken = _periodChangeCts.Token; + bool signaled; + try + { + signaled = await WaitForSignalAsync(delay, periodChangeToken, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return ReadPeriod(); + } + + if (signaled is false && 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 is false) + { + return period; // Normal timeout — next wait uses normal period. + } + + return ComputeNextDelayFromTriggerState(delay, waitStart, period); + } } /// - /// Executes the task. + /// Computes the next wait cycle's delay basis from the pending , consuming it in the process. /// - /// The task state. - public virtual async void ExecuteAsync(object? state) + /// The delay that was being waited on when the trigger arrived. + /// The timestamp at which the wait started, used to measure how much of remains. + /// The current period, used by the and strategies. + /// + /// The delay basis for the next wait cycle. + /// + private TimeSpan ComputeNextDelayFromTriggerState(TimeSpan delay, long waitStart, TimeSpan period) { - var sw = Stopwatch.StartNew(); - try + TriggerState triggerState = Interlocked.Exchange(ref _triggerState, TriggerState.Default); + if (triggerState.Delay.HasValue) { - // 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."); + return triggerState.Delay.Value; } - 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); + 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 == Timeout.InfiniteTimeSpan || period == Timeout.InfiniteTimeSpan + ? Timeout.InfiniteTimeSpan + : remaining + period; + case NextExecutionStrategy.Reset: + default: + return period; } } /// - /// Executes the core logic of the recurring hosted service asynchronously. + /// Implements the work of the recurring task. + /// + /// A cancellation token that is signaled when the host is shutting down. + /// + /// A task representing the asynchronous operation. + /// + 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 + + /// + /// Implements the work of the recurring task. /// - /// An optional object containing state information for the execution. - /// A that represents the asynchronous execution of the recurring task. - public abstract Task PerformExecuteAsync(object? state); + /// The task state. + /// + /// A task representing the asynchronous operation. + /// + /// + /// This overload does not receive a , so shutdown cancellation is not propagated to the implementation. + /// + [Obsolete("Override PerformExecuteAsync(CancellationToken) instead. Scheduled for removal in Umbraco 19.")] + public virtual Task PerformExecuteAsync(object? state) + => Task.CompletedTask; + + /// + /// Executes the task. + /// + /// The task state. + [Obsolete("No longer used. The base class now uses BackgroundService.ExecuteAsync(CancellationToken). Scheduled for removal in Umbraco 19.")] + public virtual void ExecuteAsync(object? state) + { } /// /// Computes the delay before the next execution, subtracting the elapsed execution time from the period to prevent drift. - /// Clamps to if execution exceeded the period. /// /// The configured period between executions. /// The elapsed time of the current execution. @@ -183,30 +282,158 @@ public virtual async void ExecuteAsync(object? state) /// internal static TimeSpan ComputeNextDelay(TimeSpan period, TimeSpan elapsed) { + if (period == Timeout.InfiniteTimeSpan) + { + return Timeout.InfiniteTimeSpan; + } + 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; } /// - /// Change the period between operations. + /// Change the period between operations. The new period takes effect immediately, interrupting the current wait if necessary. /// - /// The new period between tasks - protected void ChangePeriod(TimeSpan newPeriod) => _period = newPeriod; + /// The new period between tasks. Set to to (temporarily) disable automatic scheduling and turn the loop into a manually triggered one; change back to a finite period to resume scheduling. + protected void ChangePeriod(TimeSpan newPeriod) + { + if (newPeriod != Timeout.InfiniteTimeSpan) + { + ArgumentOutOfRangeException.ThrowIfLessThan(newPeriod, TimeSpan.Zero); + } + 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(); + } + + /// + /// 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. + /// + /// + protected internal void TriggerExecution() + => TriggerExecution(NextExecutionStrategy.None); + + /// + /// Signals the background loop to execute immediately, with the specified strategy for determining the next execution after the triggered one completes. + /// + /// Controls the delay after the triggered execution. + protected internal void TriggerExecution(NextExecutionStrategy strategy) + { + Interlocked.Exchange(ref _triggerState, new TriggerState(Strategy: strategy)); + ReleaseSignal(); + } + + /// + /// 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). + /// + /// The target interval from execution start to the next execution. Execution time is subtracted to prevent drift. Set to to leave the loop in manually triggered mode after this execution. + protected internal void TriggerExecution(TimeSpan nextDelay) + { + if (nextDelay != Timeout.InfiniteTimeSpan) + { + ArgumentOutOfRangeException.ThrowIfLessThan(nextDelay, TimeSpan.Zero); + } + + Interlocked.Exchange(ref _triggerState, new TriggerState(Delay: nextDelay)); + ReleaseSignal(); + } + + /// + /// Reads the current period in a thread-safe manner. + /// + /// + /// The current period between executions. + /// + private TimeSpan ReadPeriod() + => TimeSpan.FromTicks(Interlocked.Read(ref _periodTicks)); + + /// + /// Waits for the semaphore to be signaled or for the timeout to expire, using the injected . + /// + /// The maximum time to wait. + /// A cancellation token that is signaled when the period changes. + /// A cancellation token for shutdown. + /// + /// true if the semaphore was signaled; false if the timeout expired or the period changed. + /// + /// Thrown when is cancelled. + private async Task 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 + } + } + + /// + /// Releases the semaphore to wake the background loop. If the semaphore is already signaled, the call is a no-op. + /// + private void ReleaseSignal() + { + try + { + _signal.Release(); + } + catch (SemaphoreFullException) + { + // Already signaled + } + } + + /// + public sealed override void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases unmanaged and optionally managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. protected virtual void Dispose(bool disposing) { - if (!_disposedValue) + if (Interlocked.CompareExchange(ref _isDisposed, 1, 0) != 0) { - if (disposing) - { - _timer?.Dispose(); - } + return; + } - _disposedValue = true; + if (disposing) + { + _signal.Dispose(); + _periodChangeCts.Dispose(); } + + base.Dispose(); + } + + /// + /// Immutable snapshot of the trigger state. + /// + private sealed record TriggerState(NextExecutionStrategy Strategy = default, TimeSpan? Delay = null) + { + /// + /// Gets the default trigger state with no strategy and no custom delay. + /// + /// + /// The default trigger state. + /// + public static TriggerState Default { get; } = new(); } } diff --git a/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobCanceledNotification.cs b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobCanceledNotification.cs new file mode 100644 index 000000000000..fbfa7ea42a31 --- /dev/null +++ b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobCanceledNotification.cs @@ -0,0 +1,19 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Infrastructure.BackgroundJobs; + +namespace Umbraco.Cms.Infrastructure.Notifications; + +/// +/// Notification that is raised when a recurring background job is cancelled during host shutdown. +/// +public sealed class RecurringBackgroundJobCanceledNotification : RecurringBackgroundJobNotification +{ + /// + /// Initializes a new instance of the class. + /// + /// The instance of the recurring background job that was cancelled. + /// The associated with the cancellation. + public RecurringBackgroundJobCanceledNotification(IRecurringBackgroundJob target, EventMessages messages) + : base(target, messages) + { } +} diff --git a/tests/Directory.Packages.props b/tests/Directory.Packages.props index 33ff4f543a8a..78f90a0d75b0 100644 --- a/tests/Directory.Packages.props +++ b/tests/Directory.Packages.props @@ -7,6 +7,7 @@ + diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/DelayCalculatorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/DelayCalculatorTests.cs new file mode 100644 index 000000000000..41d626a113c3 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/DelayCalculatorTests.cs @@ -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(); + 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(); + 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(); + 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(); + 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(y => y == LogLevel.Warning), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Test] + public void GetDelay_With_TimeProvider_Uses_Provider_Time() + { + var cronTabParser = new NCronTabParser(); + var logger = Mock.Of(); + 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); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceRunnerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceRunnerTests.cs new file mode 100644 index 000000000000..4722a9a554b9 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceRunnerTests.cs @@ -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(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(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(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(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(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(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>(); + Func 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, ITriggerableRecurringBackgroundJob + { + private readonly Func? _onExecute; + + public TestJobA(Func? 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, ITriggerableRecurringBackgroundJob + { + public override TimeSpan Period => TimeSpan.FromSeconds(30); + + public override TimeSpan Delay => TimeSpan.Zero; + + public override Task RunJobAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + /// + /// Minimal hosted service that wraps a job, inheriting from RecurringHostedServiceBase + /// so the runner can cast and call TriggerExecution. + /// + 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); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceTests.cs index 806520bf4145..d8d74d13440a 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceTests.cs @@ -2,17 +2,15 @@ // See LICENSE for more details. using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Time.Testing; 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 +18,6 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs; [TestFixture] public class RecurringBackgroundJobHostedServiceTests { - [TestCase(RuntimeLevel.Boot)] [TestCase(RuntimeLevel.Install)] [TestCase(RuntimeLevel.Unknown)] @@ -31,9 +28,9 @@ public async Task Does_Not_Execute_When_Runtime_State_Is_Not_Run(RuntimeLevel ru var mockJob = new Mock(); 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()), Times.Never); } [Test] @@ -43,7 +40,7 @@ public async Task Publishes_Ignored_Notification_When_Runtime_State_Is_Not_Run() var mockEventAggregator = new Mock(); 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(), It.IsAny()), Times.Once); mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); @@ -56,9 +53,9 @@ public async Task Does_Not_Execute_When_Server_Role_Is_NotDefault(ServerRole ser var mockJob = new Mock(); 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()), Times.Never); } [TestCase(ServerRole.Single)] @@ -66,12 +63,12 @@ public async Task Does_Not_Execute_When_Server_Role_Is_NotDefault(ServerRole ser public async Task Does_Executes_When_Server_Role_Is_Default(ServerRole serverRole) { var mockJob = new Mock(); - 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()), Times.Once); } [Test] @@ -81,9 +78,9 @@ public async Task Does_Execute_When_Server_Role_Is_Subscriber_And_Specified() 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()), Times.Once); } [Test] @@ -93,7 +90,7 @@ public async Task Publishes_Ignored_Notification_When_Server_Role_Is_Not_Allowed var mockEventAggregator = new Mock(); 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(), It.IsAny()), Times.Once); mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); @@ -105,9 +102,9 @@ public async Task Does_Not_Execute_When_Not_Main_Dom() var mockJob = new Mock(); 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()), Times.Never); } [Test] @@ -117,23 +114,21 @@ public async Task Publishes_Ignored_Notification_When_Not_Main_Dom() var mockEventAggregator = new Mock(); var sut = CreateRecurringBackgroundJobHostedService(mockJob, isMainDom: false, mockEventAggregator: mockEventAggregator); - await sut.PerformExecuteAsync(null); + await sut.PerformExecuteAsync(CancellationToken.None); mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); } - - [Test] public async Task Publishes_Executed_Notification_When_Run() { var mockJob = new Mock(); - mockJob.Setup(x => x.ServerRoles).Returns(IRecurringBackgroundJob.DefaultServerRoles); + mockJob.Setup(x => x.ServerRoles).Returns(RecurringBackgroundJobBase.DefaultServerRoles); var mockEventAggregator = new Mock(); var sut = CreateRecurringBackgroundJobHostedService(mockJob, mockEventAggregator: mockEventAggregator); - await sut.PerformExecuteAsync(null); + await sut.PerformExecuteAsync(CancellationToken.None); mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); @@ -143,17 +138,34 @@ public async Task Publishes_Executed_Notification_When_Run() public async Task Publishes_Failed_Notification_When_Fails() { var mockJob = new Mock(); - mockJob.Setup(x => x.ServerRoles).Returns(IRecurringBackgroundJob.DefaultServerRoles); - mockJob.Setup(x => x.RunJobAsync()).Throws(); + mockJob.Setup(x => x.ServerRoles).Returns(RecurringBackgroundJobBase.DefaultServerRoles); + mockJob.Setup(x => x.RunJobAsync(It.IsAny())).Throws(); var mockEventAggregator = new Mock(); var sut = CreateRecurringBackgroundJobHostedService(mockJob, mockEventAggregator: mockEventAggregator); - await sut.PerformExecuteAsync(null); + await sut.PerformExecuteAsync(CancellationToken.None); mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); } + [Test] + public async Task Publishes_Canceled_Notification_When_Canceled() + { + using var cts = new CancellationTokenSource(); + var mockJob = new Mock(); + mockJob.Setup(x => x.ServerRoles).Returns(RecurringBackgroundJobBase.DefaultServerRoles); + mockJob.Setup(x => x.RunJobAsync(It.IsAny())) + .Returns(ct => { cts.Cancel(); ct.ThrowIfCancellationRequested(); return Task.CompletedTask; }); + var mockEventAggregator = new Mock(); + + var sut = CreateRecurringBackgroundJobHostedService(mockJob, mockEventAggregator: mockEventAggregator); + await sut.PerformExecuteAsync(cts.Token); + + mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + } + [Test] public async Task Publishes_Start_And_Stop_Notifications() { @@ -164,24 +176,120 @@ public async Task Publishes_Start_And_Stop_Notifications() await sut.StartAsync(CancellationToken.None); await sut.StopAsync(CancellationToken.None); - mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); - - mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task Waits_When_Execution_Is_Ignored() + { + var timeProvider = new FakeTimeProvider(); + var mockJob = new Mock(); + mockJob.Setup(x => x.IgnoredDelay).Returns(TimeSpan.FromSeconds(30)); + + var sut = CreateRecurringBackgroundJobHostedService(mockJob, isMainDom: false, timeProvider: timeProvider); + Task executeTask = sut.PerformExecuteAsync(CancellationToken.None); + + // The notification publishes synchronously through the mocks; the back-off should keep PerformExecuteAsync pending. + Task completedFirst = await Task.WhenAny(executeTask, Task.Delay(TimeSpan.FromMilliseconds(100))); + Assert.AreNotSame(executeTask, completedFirst, "Back-off should keep execution pending until time advances"); + + timeProvider.Advance(TimeSpan.FromSeconds(30)); + await executeTask.WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Test] + public async Task Wait_Honors_Custom_IgnoredDelay_From_Job() + { + var timeProvider = new FakeTimeProvider(); + var mockJob = new Mock(); + mockJob.Setup(x => x.IgnoredDelay).Returns(TimeSpan.FromMinutes(5)); + var sut = CreateRecurringBackgroundJobHostedService(mockJob, isMainDom: false, timeProvider: timeProvider); + Task executeTask = sut.PerformExecuteAsync(CancellationToken.None); + + Task completedFirst = await Task.WhenAny(executeTask, Task.Delay(TimeSpan.FromMilliseconds(100))); + Assert.AreNotSame(executeTask, completedFirst, "Back-off should be in progress"); + + // Advance less than the configured IgnoredDelay — still pending. + timeProvider.Advance(TimeSpan.FromMinutes(4)); + completedFirst = await Task.WhenAny(executeTask, Task.Delay(TimeSpan.FromMilliseconds(100))); + Assert.AreNotSame(executeTask, completedFirst, "Should still be backing off"); + + // Advance the remainder — back-off completes. + timeProvider.Advance(TimeSpan.FromMinutes(1)); + await executeTask.WaitAsync(TimeSpan.FromSeconds(5)); } + [Test] + public async Task Skips_Wait_When_IgnoredDelay_Is_Zero() + { + var timeProvider = new FakeTimeProvider(); + var mockJob = new Mock(); + mockJob.Setup(x => x.IgnoredDelay).Returns(TimeSpan.Zero); + + var sut = CreateRecurringBackgroundJobHostedService(mockJob, isMainDom: false, timeProvider: timeProvider); + + // No time advance required — back-off is skipped entirely. + await sut.PerformExecuteAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Test] + public async Task Waits_Until_Shutdown_When_IgnoredDelay_Is_Infinite() + { + var timeProvider = new FakeTimeProvider(); + using var cts = new CancellationTokenSource(); + var mockJob = new Mock(); + mockJob.Setup(x => x.IgnoredDelay).Returns(Timeout.InfiniteTimeSpan); + + var sut = CreateRecurringBackgroundJobHostedService(mockJob, isMainDom: false, timeProvider: timeProvider); + Task executeTask = sut.PerformExecuteAsync(cts.Token); + + // Advancing time arbitrarily must not complete the back-off — infinite means "wait until shutdown". + timeProvider.Advance(TimeSpan.FromDays(1)); + Task completedFirst = await Task.WhenAny(executeTask, Task.Delay(TimeSpan.FromMilliseconds(100))); + Assert.AreNotSame(executeTask, completedFirst, "Back-off should remain pending regardless of elapsed time"); - private RecurringHostedServiceBase CreateRecurringBackgroundJobHostedService( + // Only cancellation releases the wait. + cts.Cancel(); + await executeTask.WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Test] + public async Task Wait_Cancellation_Does_Not_Publish_Canceled_Notification() + { + var timeProvider = new FakeTimeProvider(); + using var cts = new CancellationTokenSource(); + var mockJob = new Mock(); + mockJob.Setup(x => x.IgnoredDelay).Returns(TimeSpan.FromHours(1)); + var mockEventAggregator = new Mock(); + + var sut = CreateRecurringBackgroundJobHostedService(mockJob, isMainDom: false, mockEventAggregator: mockEventAggregator, timeProvider: timeProvider); + Task executeTask = sut.PerformExecuteAsync(cts.Token); + + Task completedFirst = await Task.WhenAny(executeTask, Task.Delay(TimeSpan.FromMilliseconds(100))); + Assert.AreNotSame(executeTask, completedFirst, "Back-off should be in progress"); + + cts.Cancel(); + await executeTask.WaitAsync(TimeSpan.FromSeconds(5)); + + mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + private RecurringBackgroundJobHostedService CreateRecurringBackgroundJobHostedService( Mock mockJob, RuntimeLevel runtimeLevel = RuntimeLevel.Run, ServerRole serverRole = ServerRole.Single, bool isMainDom = true, - Mock mockEventAggregator = null) + Mock mockEventAggregator = null, + TimeProvider timeProvider = null) { + mockJob.Setup(x => x.Period).Returns(TimeSpan.FromMinutes(5)); + mockJob.Setup(x => x.Delay).Returns(TimeSpan.Zero); + var mockRunTimeState = new Mock(); mockRunTimeState.SetupGet(x => x.Level).Returns(runtimeLevel); @@ -203,6 +311,8 @@ private RecurringHostedServiceBase CreateRecurringBackgroundJobHostedService( mockMainDom.Object, mockServerRegistrar.Object, mockEventAggregator.Object, - mockJob.Object); + Mock.Of(f => f.Get() == new EventMessages()), + mockJob.Object, + timeProvider ?? TimeProvider.System); } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBaseTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBaseTests.cs index 229f63b3ec56..478d8b811b16 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBaseTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBaseTests.cs @@ -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,595 @@ 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(-2, 1_000, 0, Description = "Returns zero for negative (non-infinite) period")] + [TestCase(-1, 1_000, -1, Description = "Preserves Timeout.InfiniteTimeSpan")] + [TestCase(-1, 0, -1, Description = "Preserves Timeout.InfiniteTimeSpan when elapsed is zero")] + public void ComputeNextDelay_Returns_Expected_Result(long periodMs, long elapsedMs, long expectedMs) { - var cronTabParser = new NCronTabParser(); - var logger = Mock.Of(); - 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); + var period = TimeSpan.FromMilliseconds(periodMs); + var elapsed = TimeSpan.FromMilliseconds(elapsedMs); + + TimeSpan result = RecurringHostedServiceBase.ComputeNextDelay(period, elapsed); + + Assert.AreEqual(TimeSpan.FromMilliseconds(expectedMs), result); + } + + [TestCase(0)] + [TestCase(-2)] + public void Constructor_Throws_For_Negative_NonInfinite_Period(long periodMs) + { + if (periodMs == 0) + { + Assert.DoesNotThrow(() => _ = new TestRecurringHostedService(TimeSpan.Zero, TimeSpan.Zero, new FakeTimeProvider(), _ => Task.CompletedTask)); + } + else + { + Assert.Throws(() => _ = new TestRecurringHostedService(TimeSpan.FromMilliseconds(periodMs), TimeSpan.Zero, new FakeTimeProvider(), _ => Task.CompletedTask)); + } + } + + [Test] + public void Constructor_Allows_Infinite_Period() + { + Assert.DoesNotThrow(() => _ = new TestRecurringHostedService(Timeout.InfiniteTimeSpan, TimeSpan.Zero, new FakeTimeProvider(), _ => Task.CompletedTask)); } [Test] - public void Returns_Notification_Delay_From_Default_When_Provided_Time_Too_Close_To_Current_Time() + public void Constructor_Allows_Infinite_Delay() { - var firstRunTime = "30 12 * * *"; - var cronTabParser = new NCronTabParser(); - var logger = Mock.Of(); - 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); + Assert.DoesNotThrow(() => _ = new TestRecurringHostedService(TimeSpan.FromMinutes(1), Timeout.InfiniteTimeSpan, new FakeTimeProvider(), _ => Task.CompletedTask)); } [Test] - public void Logs_And_Returns_Notification_Delay_From_Default_When_Provided_Time_Is_Not_Valid() + public void ChangePeriod_Allows_Infinite() { - var firstRunTime = "invalid"; - var cronTabParser = new NCronTabParser(); - var logger = new Mock(); - 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); + var sut = new TestRecurringHostedService(TimeSpan.FromMinutes(1), TimeSpan.Zero, new FakeTimeProvider(), _ => Task.CompletedTask); - logger.Verify( - logger => logger.Log( - It.Is(y => y == LogLevel.Warning), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>()), - Times.Once); + Assert.DoesNotThrow(() => sut.PublicChangePeriod(Timeout.InfiniteTimeSpan)); } [Test] - public void ComputeNextDelay_Subtracts_Elapsed_Time_From_Period() + public void TriggerExecution_NextDelay_Allows_Infinite() { - var period = TimeSpan.FromSeconds(10); - var elapsed = TimeSpan.FromSeconds(3); + var sut = new TestRecurringHostedService(TimeSpan.FromMinutes(1), TimeSpan.Zero, new FakeTimeProvider(), _ => Task.CompletedTask); - TimeSpan result = RecurringHostedServiceBase.ComputeNextDelay(period, elapsed); + Assert.DoesNotThrow(() => sut.PublicTriggerExecutionWithDelay(Timeout.InfiniteTimeSpan)); + } + + [Test] + public async Task Infinite_Delay_Skips_Initial_Execution_Until_Triggered() + { + var executionCount = 0; + var timeProvider = new FakeTimeProvider(); + using var executed = new SemaphoreSlim(0); + var sut = new TestRecurringHostedService( + period: TimeSpan.FromMinutes(10), + delay: Timeout.InfiniteTimeSpan, + timeProvider: timeProvider, + onExecute: _ => { Interlocked.Increment(ref executionCount); executed.Release(); return Task.CompletedTask; }); + + using var cts = new CancellationTokenSource(); + await sut.StartAsync(cts.Token); + + // Advancing time does not fire the first execution — initial delay is infinite. + timeProvider.Advance(TimeSpan.FromHours(24)); + Assert.IsFalse(await executed.WaitAsync(TimeSpan.FromMilliseconds(50)), "Should not execute on its own when initial delay is infinite"); + + // Manual trigger fires the first execution. + sut.PublicTriggerExecution(); + Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)), "Triggered first execution should complete"); + Assert.AreEqual(1, executionCount); - Assert.AreEqual(TimeSpan.FromSeconds(7), result); + // After the first execution, the normal period applies — advance 10 min to fire the second. + timeProvider.Advance(TimeSpan.FromMinutes(10)); + Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)), "Second execution should fire on Period after the triggered first run"); + Assert.AreEqual(2, executionCount); + + cts.Cancel(); + await sut.StopAsync(CancellationToken.None); } [Test] - public void ComputeNextDelay_Returns_Zero_When_Execution_Exceeds_Period() + public async Task Infinite_Delay_And_Infinite_Period_Runs_Only_When_Triggered() { - 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: Timeout.InfiniteTimeSpan, + delay: Timeout.InfiniteTimeSpan, + 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); + + timeProvider.Advance(TimeSpan.FromHours(24)); + Assert.IsFalse(await executed.WaitAsync(TimeSpan.FromMilliseconds(50)), "Should not execute without a trigger"); + + sut.PublicTriggerExecution(); + Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.AreEqual(1, executionCount); + + // Period is infinite, so no further executions without another trigger. + timeProvider.Advance(TimeSpan.FromHours(24)); + Assert.IsFalse(await executed.WaitAsync(TimeSpan.FromMilliseconds(50)), "Should not execute again without a trigger"); - Assert.AreEqual(TimeSpan.Zero, result); + sut.PublicTriggerExecution(); + Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.AreEqual(2, executionCount); + + cts.Cancel(); + await sut.StopAsync(CancellationToken.None); } [Test] - public void ComputeNextDelay_Returns_Full_Period_When_Elapsed_Is_Zero() + public async Task Infinite_Period_Waits_For_Trigger_Only() { - 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: Timeout.InfiniteTimeSpan, + 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); + + // First execution fires (delay = Zero — no initial wait) + Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)), "Initial execution should complete"); + Assert.AreEqual(1, executionCount); + + // Time advancing should not trigger another execution + timeProvider.Advance(TimeSpan.FromHours(24)); + Assert.IsFalse(await executed.WaitAsync(TimeSpan.FromMilliseconds(50)), "Should not execute on its own when period is infinite"); - Assert.AreEqual(period, result); + // Manual trigger fires the next execution + sut.PublicTriggerExecution(); + Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)), "Triggered execution should complete"); + Assert.AreEqual(2, executionCount); + + // After the trigger, the next cycle should still wait for another trigger + timeProvider.Advance(TimeSpan.FromHours(24)); + Assert.IsFalse(await executed.WaitAsync(TimeSpan.FromMilliseconds(50)), "Should resume infinite wait after trigger"); + + cts.Cancel(); + await sut.StopAsync(CancellationToken.None); } [Test] - public void ComputeNextDelay_Returns_Zero_When_Execution_Equals_Period() + public async Task ChangePeriod_From_Infinite_To_Finite_Resumes_Scheduling() { - 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: Timeout.InfiniteTimeSpan, + 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.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.AreEqual(1, executionCount); - Assert.AreEqual(TimeSpan.Zero, result); + // Switch to a 10-minute period — the in-flight infinite wait should be interrupted. + sut.PublicChangePeriod(TimeSpan.FromMinutes(10)); + + timeProvider.Advance(TimeSpan.FromMinutes(10)); + Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5)), "Should execute on schedule after switching to finite period"); + Assert.AreEqual(2, executionCount); + + // Switch back to infinite — schedule disabled again. + sut.PublicChangePeriod(Timeout.InfiniteTimeSpan); + timeProvider.Advance(TimeSpan.FromHours(24)); + Assert.IsFalse(await executed.WaitAsync(TimeSpan.FromMilliseconds(50)), "Should not execute after switching back to infinite"); + + cts.Cancel(); + await sut.StopAsync(CancellationToken.None); } [Test] - public void ComputeNextDelay_Returns_Zero_For_Negative_Period() + public async Task Loop_Executes_Periodically_And_Respects_Cancellation() { - 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.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.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 async Task TriggerExecution_Causes_Immediate_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: _ => { 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)), "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 async Task TriggerExecution_Reset_Starts_New_Full_Period() + { + 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 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 async Task TriggerExecution_None_Resumes_Original_Wait() + { + 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 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_Honors_Custom_Delay() + { + 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 5-minute next-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 5-minute delay applies to the next wait — consistent with TriggerExecution(TimeSpan) docs. + timeProvider.Advance(TimeSpan.FromMinutes(4)); + Assert.IsFalse(await executed.WaitAsync(TimeSpan.FromMilliseconds(50)), "Should not execute before the custom next-delay elapses"); + + timeProvider.Advance(TimeSpan.FromMinutes(1)); + Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.AreEqual(2, executionCount, "Second execution fires after the custom 5-minute delay"); + + // After the trigger's custom delay is consumed, the loop resumes the normal 1-hour period. + timeProvider.Advance(TimeSpan.FromMinutes(59)); + Assert.IsFalse(await executed.WaitAsync(TimeSpan.FromMilliseconds(50)), "Should not execute before the normal period elapses"); + + timeProvider.Advance(TimeSpan.FromMinutes(1)); + Assert.IsTrue(await executed.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.AreEqual(3, executionCount, "Third execution fires after the normal 1-hour 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); + } + + /// + /// A concrete test subclass that overrides the new PerformExecuteAsync(CancellationToken). + /// + private class TestRecurringHostedService : RecurringHostedServiceBase + { + private readonly Func _onExecute; + + public TestRecurringHostedService(TimeSpan period, TimeSpan delay, TimeProvider timeProvider, Func 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); - Assert.AreEqual(TimeSpan.Zero, result); + public void PublicChangePeriod(TimeSpan newPeriod) => ChangePeriod(newPeriod); } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj index a643d6579968..4f4297f8b3ac 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj @@ -27,6 +27,7 @@ +