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 @@
+