Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
3c2f198
Compute next delay to compensate for time drift
ronaldbarendse Mar 7, 2026
a2d97f8
Use SemaphoreSlim to properly handle exceptions, cancellation tokens …
ronaldbarendse Mar 9, 2026
8fe60d6
Add RecurringBackgroundJobBase to contain default values and hide obs…
ronaldbarendse Apr 1, 2026
d1ec78d
Add NextExecutionStrategy parameter to adjust the schedule after trig…
ronaldbarendse Apr 1, 2026
9f8b533
Add TriggerExecution methods to RecurringBackgroundJobHostedServiceRu…
ronaldbarendse Apr 1, 2026
4f50356
Merge branch 'main' into v17/feature/recurringbackgroundjob-signalling
ronaldbarendse Apr 2, 2026
de7e72e
Handle cancellation (application shutdown) and publish RecurringBackg…
ronaldbarendse Apr 2, 2026
4eca474
Match hosted services by Type instead of type name string
ronaldbarendse Apr 2, 2026
d226d8c
Extract shared helper for TriggerExecution tests
ronaldbarendse Apr 2, 2026
504a56c
Clear trigger state when initial delay is interrupted
ronaldbarendse Apr 2, 2026
7aaa786
Clear _nextExecutionSkipOnOvershoot unconditionally
ronaldbarendse Apr 2, 2026
42f15d8
Combine ComputeNextDelay tests
ronaldbarendse Apr 2, 2026
7f29069
Consolidate trigger state into an immutable record for thread safety
ronaldbarendse Apr 2, 2026
3607092
Use ConcurrentDictionary for thread-safe hosted service lookup
ronaldbarendse Apr 2, 2026
3012138
Remove hosted services from dictionary on stop
ronaldbarendse Apr 2, 2026
7d82afa
Fix API compatibility errors
ronaldbarendse Apr 2, 2026
9e510e0
Removed unneeded using.
AndyButland Apr 3, 2026
417c7ae
Register RecurringBackgroundJobHostedServiceRunner as resolvable sing…
ronaldbarendse Apr 7, 2026
34e51e4
Remove failed hosted service from dictionary when StartAsync throws
ronaldbarendse Apr 7, 2026
524a56d
Use semaphore signaling instead of Task.Delay in trigger tests
ronaldbarendse Apr 7, 2026
e984e0e
Inject TimeProvider into RecurringHostedServiceBase for deterministic…
ronaldbarendse Apr 7, 2026
2a3df70
Use DelayCalculator.GetDelay instead of RecurringHostedServiceBase.Ge…
ronaldbarendse Apr 7, 2026
d245c02
Merge branch 'main' into v17/feature/recurringbackgroundjob-signalling
ronaldbarendse Apr 7, 2026
5d1b7da
Fix Exception_In_PerformExecuteAsync_Does_Not_Kill_Loop test
ronaldbarendse Apr 7, 2026
3f1325e
Avoid disposing period-change CTS while wait loop may still reference it
ronaldbarendse Apr 8, 2026
a2e11c7
Configure IEventMessagesFactory mock to return real EventMessages
ronaldbarendse Apr 8, 2026
9c9518d
Clarify TriggerExecution(TimeSpan) docs and add ChangePeriod test
ronaldbarendse Apr 8, 2026
312c20f
Validate period is positive and use GetOrAdd to avoid creating unused…
ronaldbarendse Apr 8, 2026
ed41368
Set up Period and Delay on mock job to satisfy constructor validation
ronaldbarendse Apr 9, 2026
48f407b
Ensure PeriodChanged event is unsubscribed again
ronaldbarendse Apr 9, 2026
7795e97
Fix trigger state race, simplify ReleaseSignal, and add canceled noti…
ronaldbarendse Apr 9, 2026
7e97d46
Use Interlocked for _period reads/writes and implement thread-safe di…
ronaldbarendse Apr 9, 2026
9c3ac1f
Remove hosted service from dictionary before stopping to prevent trig…
ronaldbarendse Apr 9, 2026
0631f0a
Replace Task.Yield with semaphore timeouts in negative assertions
ronaldbarendse Apr 9, 2026
e078072
Tidy RecurringBackgroundJobBase docs and runner error handling
ronaldbarendse Apr 16, 2026
49a1f44
Wait IgnoredDelay after ignored execution to prevent tight looping wh…
ronaldbarendse May 18, 2026
4465ed5
Add IRecurringBackgroundJobTrigger<TJob> for opt-in job triggering
ronaldbarendse Apr 16, 2026
e19877a
Merge branch 'v17/dev' into v17/feature/recurringbackgroundjob-signal…
ronaldbarendse May 18, 2026
ca057fe
Register IRecurringBackgroundJobTrigger as open generic and drop AddT…
ronaldbarendse May 18, 2026
87059b7
Fix and add parameter validation
ronaldbarendse May 19, 2026
702a544
Allow Timeout.InfiniteTimeSpan as Period for manual-trigger-only recu…
ronaldbarendse May 19, 2026
c5b14cc
Migrate built-in jobs to RecurringBackgroundJobBase and require ITrig…
ronaldbarendse May 19, 2026
7e511ec
Support infinite Delay and honor TriggerExecution(TimeSpan) issued du…
ronaldbarendse May 19, 2026
4578aeb
Merge branch 'v17/dev' into v17/feature/recurringbackgroundjob-signal…
AndyButland May 21, 2026
d8938a8
Handle edge case of backoff via InfiniteTimeSpan.
AndyButland May 21, 2026
68cd735
Refactored large method.
AndyButland May 21, 2026
7f07265
Added clarifying documentation.
AndyButland May 21, 2026
1aa631c
Suppress ExecutionContext flow when starting the recurring background…
AndyButland May 21, 2026
ca75501
Relocate Suppress ExecutionContext flow to avoid package validation e…
AndyButland May 21, 2026
cee97a8
Align IRecurringBackgroundJobTrigger generic type constraint with Add…
ronaldbarendse May 21, 2026
4c3f7a7
Rename ApplyTriggerState to ComputeNextDelayFromTriggerState
ronaldbarendse May 21, 2026
1e2f285
Allow Timeout.InfiniteTimeSpan as IgnoredDelay to fully disable a job…
ronaldbarendse May 21, 2026
edaf8fa
Fix generic type constraint
ronaldbarendse May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/Umbraco.Infrastructure/BackgroundJobs/DelayCalculator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ internal static TimeSpan GetDelay(
}

// 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,38 +1,71 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Sync;

namespace Umbraco.Cms.Infrastructure.BackgroundJobs;

/// <summary>
/// A recurring background job
/// A recurring background job.
/// </summary>
public interface IRecurringBackgroundJob
{
static readonly TimeSpan DefaultDelay = System.TimeSpan.FromMinutes(3);
static readonly ServerRole[] DefaultServerRoles = new[] { ServerRole.Single, ServerRole.SchedulingPublisher };
/// <summary>
/// The default delay to use for recurring tasks for the first run after application start-up if no alternative is configured.
/// </summary>
[Obsolete("Use RecurringBackgroundJobBase.DefaultDelay instead. Scheduled for removal in Umbraco 19.")]
static readonly TimeSpan DefaultDelay = RecurringBackgroundJobBase.DefaultDelay;

/// <summary>
/// The default server roles that recurring background jobs run on.
/// </summary>
[Obsolete("Use RecurringBackgroundJobBase.DefaultServerRoles instead. Scheduled for removal in Umbraco 19.")]
static readonly ServerRole[] DefaultServerRoles = RecurringBackgroundJobBase.DefaultServerRoles;

/// <summary>
/// Timespan representing how often the task should recur.
/// </summary>
/// <value>
/// The period.
/// </value>
TimeSpan Period { get; }

/// <summary>
/// Timespan representing the initial delay after application start-up before the first run of the task
/// occurs.
/// Timespan representing the initial delay after application start-up before the first run of the task occurs.
/// </summary>
TimeSpan Delay { get => DefaultDelay; }
/// <value>
/// The delay.
/// </value>
TimeSpan Delay => RecurringBackgroundJobBase.DefaultDelay; // TODO (V19): Remove the default implementation

/// <summary>
/// Gets the server roles for which this recurring background job is intended.
/// Gets the server roles the task executes on.
/// </summary>
ServerRole[] ServerRoles { get => DefaultServerRoles; }
/// <value>
/// The server roles.
/// </value>
ServerRole[] ServerRoles => RecurringBackgroundJobBase.DefaultServerRoles; // TODO (V19): Remove the default implementation

/// <summary>
/// This event should be raised when the <see cref="Period" /> property changes to notify the background job manager to update the schedule for this job.
/// </summary>
event EventHandler PeriodChanged;

/// <summary>
/// Executes the logic associated with the recurring background job asynchronously.
/// Runs the background job.
/// </summary>
/// <returns>A <see cref="System.Threading.Tasks.Task"/> that represents the asynchronous execution of the background job.</returns>
/// <returns>
/// A task representing the asynchronous operation.
/// </returns>
[Obsolete("Use RunJobAsync(CancellationToken) instead. This method will be removed in Umbraco 19.")]
Task RunJobAsync();
}

/// <summary>
/// Runs the background job with cancellation support.
/// </summary>
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
/// <returns>
/// A task representing the asynchronous operation.
/// </returns>
Task RunJobAsync(CancellationToken cancellationToken)
#pragma warning disable CS0618 // Type or member is obsolete
=> RunJobAsync(); // TODO (V19): Remove the default implementation when RunJobAsync() is removed
#pragma warning restore CS0618 // Type or member is obsolete
Comment thread
ronaldbarendse marked this conversation as resolved.
Outdated
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Umbraco.Cms.Core.Sync;

namespace Umbraco.Cms.Infrastructure.BackgroundJobs;

/// <summary>
/// Base class for recurring background jobs that provides default values for common properties.
/// </summary>
/// <remarks>
/// Implementors only need to provide <see cref="Period" /> and <see cref="RunJobAsync(CancellationToken)" />.
/// </remarks>
public abstract class RecurringBackgroundJobBase : IRecurringBackgroundJob
{
protected internal static readonly TimeSpan DefaultDelay = TimeSpan.FromMinutes(3);
protected internal static readonly ServerRole[] DefaultServerRoles = [ServerRole.Single, ServerRole.SchedulingPublisher];

/// <inheritdoc />
public abstract TimeSpan Period { get; }

/// <inheritdoc />
public virtual TimeSpan Delay => DefaultDelay;

/// <inheritdoc />
public virtual ServerRole[] ServerRoles => DefaultServerRoles;

/// <inheritdoc />
public virtual event EventHandler PeriodChanged { add { } remove { } }

/// <inheritdoc />
[Obsolete("Use RunJobAsync(CancellationToken) instead. This method will be removed in Umbraco 19.")]
public Task RunJobAsync() => RunJobAsync(CancellationToken.None);

/// <inheritdoc />
public abstract Task RunJobAsync(CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
AndyButland marked this conversation as resolved.
using Umbraco.Cms.Core.Sync;
using Umbraco.Cms.Infrastructure.HostedServices;
using Umbraco.Cms.Infrastructure.Notifications;

namespace Umbraco.Cms.Infrastructure.BackgroundJobs;

Expand All @@ -23,45 +22,50 @@ public static class RecurringBackgroundJobHostedService
/// Creates a factory function that produces hosted services for recurring background jobs.
/// </summary>
/// <param name="serviceProvider">The service provider used to create hosted service instances.</param>
/// <returns>A function that takes an <see cref="IRecurringBackgroundJob"/> and returns an <see cref="IHostedService"/>.</returns>
public static Func<IRecurringBackgroundJob, IHostedService> CreateHostedServiceFactory(IServiceProvider serviceProvider) =>
(IRecurringBackgroundJob job) =>
/// <returns>
/// A function that takes an <see cref="IRecurringBackgroundJob" /> and returns an <see cref="IHostedService" />.
/// </returns>
public static Func<IRecurringBackgroundJob, IHostedService> CreateHostedServiceFactory(IServiceProvider serviceProvider)
=> (IRecurringBackgroundJob job) =>
{
Type hostedServiceType = typeof(RecurringBackgroundJobHostedService<>).MakeGenericType(job.GetType());

return (IHostedService)ActivatorUtilities.CreateInstance(serviceProvider, hostedServiceType, job);
};
}

/// <summary>
/// Runs a recurring background job inside a hosted service.
/// Generic version for DependencyInjection
/// </summary>
/// <typeparam name="TJob">Type of the Job</typeparam>
public class RecurringBackgroundJobHostedService<TJob> : RecurringHostedServiceBase where TJob : IRecurringBackgroundJob
/// <typeparam name="TJob">The type of the job.</typeparam>
public class RecurringBackgroundJobHostedService<TJob> : RecurringHostedServiceBase
where TJob : IRecurringBackgroundJob
{

private readonly IRuntimeState _runtimeState;
private readonly ILogger<RecurringBackgroundJobHostedService<TJob>> _logger;
private readonly IMainDom _mainDom;
private readonly IRuntimeState _runtimeState;
private readonly IServerRoleAccessor _serverRoleAccessor;
private readonly IEventAggregator _eventAggregator;
private readonly IEventMessagesFactory _eventMessagesFactory;
private readonly IRecurringBackgroundJob _job;

/// <summary>
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedService{TJob}"/> class, which manages the execution of a recurring background job.
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedService{TJob}" /> class, which manages the execution of a recurring background job.
/// </summary>
/// <param name="runtimeState">Provides information about the current runtime state of the Umbraco application.</param>
/// <param name="logger">The logger used to record diagnostic and operational information for this hosted service.</param>
/// <param name="mainDom">The main domain instance responsible for coordinating single-instance operations across multiple application domains.</param>
/// <param name="serverRoleAccessor">Determines the current server's role in a multi-server environment.</param>
/// <param name="eventAggregator">Handles the publishing and subscribing of application events.</param>
/// <param name="eventMessagesFactory">The event messages factory.</param>
/// <param name="job">The recurring background job instance to be managed and executed by this service.</param>
public RecurringBackgroundJobHostedService(
IRuntimeState runtimeState,
ILogger<RecurringBackgroundJobHostedService<TJob>> logger,
IMainDom mainDom,
IServerRoleAccessor serverRoleAccessor,
IEventAggregator eventAggregator,
IEventMessagesFactory eventMessagesFactory,
TJob job)
: base(logger, job.Period, job.Delay)
{
Expand All @@ -70,87 +74,104 @@ public RecurringBackgroundJobHostedService(
_mainDom = mainDom;
_serverRoleAccessor = serverRoleAccessor;
_eventAggregator = eventAggregator;
_eventMessagesFactory = eventMessagesFactory;
_job = job;

_job.PeriodChanged += (sender, e) => ChangePeriod(_job.Period);
}

/// <summary>
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedService{TJob}" /> class, which manages the execution of a recurring background job.
/// </summary>
/// <param name="runtimeState">Provides information about the current runtime state of the Umbraco application.</param>
/// <param name="logger">The logger used to record diagnostic and operational information for this hosted service.</param>
/// <param name="mainDom">The main domain instance responsible for coordinating single-instance operations across multiple application domains.</param>
/// <param name="serverRoleAccessor">Determines the current server's role in a multi-server environment.</param>
/// <param name="eventAggregator">Handles the publishing and subscribing of application events.</param>
/// <param name="job">The recurring background job instance to be managed and executed by this service.</param>
[Obsolete("Use the overload accepting IEventMessagesFactory instead. This overload will be removed in Umbraco 19.")]
public RecurringBackgroundJobHostedService(
IRuntimeState runtimeState,
ILogger<RecurringBackgroundJobHostedService<TJob>> logger,
IMainDom mainDom,
IServerRoleAccessor serverRoleAccessor,
IEventAggregator eventAggregator,
TJob job)
: this(runtimeState, logger, mainDom, serverRoleAccessor, eventAggregator, StaticServiceProvider.Instance.GetRequiredService<IEventMessagesFactory>(), job)
{ }

/// <inheritdoc />
public override async Task PerformExecuteAsync(object? state)
public override async Task PerformExecuteAsync(CancellationToken stoppingToken)
{
var executingNotification = new Notifications.RecurringBackgroundJobExecutingNotification(_job, new EventMessages());
await _eventAggregator.PublishAsync(executingNotification);
EventMessages eventMessages = _eventMessagesFactory.Get();
var executingNotification = new RecurringBackgroundJobExecutingNotification(_job, eventMessages);
await _eventAggregator.PublishAsync(executingNotification, stoppingToken);

try
{

if (_runtimeState.Level != RuntimeLevel.Run)
{
_logger.LogDebug("Job not running as runlevel not yet ready");
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobIgnoredNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
await _eventAggregator.PublishAsync(new RecurringBackgroundJobIgnoredNotification(_job, eventMessages).WithStateFrom(executingNotification), stoppingToken);
return;
}

// 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 _eventAggregator.PublishAsync(new RecurringBackgroundJobIgnoredNotification(_job, eventMessages).WithStateFrom(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 _eventAggregator.PublishAsync(new RecurringBackgroundJobIgnoredNotification(_job, eventMessages).WithStateFrom(executingNotification), stoppingToken);
return;
}


await _job.RunJobAsync();
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobExecutedNotification(_job, new EventMessages()).WithStateFrom(executingNotification));


await _job.RunJobAsync(stoppingToken);
await _eventAggregator.PublishAsync(new RecurringBackgroundJobExecutedNotification(_job, eventMessages).WithStateFrom(executingNotification), stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
_logger.LogDebug("Job canceled during shutdown.");
await _eventAggregator.PublishAsync(new RecurringBackgroundJobCanceledNotification(_job, eventMessages).WithStateFrom(executingNotification), CancellationToken.None);
Comment thread
ronaldbarendse marked this conversation as resolved.
}
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);
}
Comment thread
ronaldbarendse marked this conversation as resolved.

}

/// <summary>
/// Asynchronously starts the recurring background job and publishes notifications before and after the job is started.
/// This method first publishes a <see cref="Notifications.RecurringBackgroundJobStartingNotification"/> prior to starting the job,
/// then calls the base implementation to start the job, and finally publishes a <see cref="Notifications.RecurringBackgroundJobStartedNotification"/>.
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A task that represents the asynchronous start operation.</returns>
/// <inheritdoc />
[Obsolete("Override PerformExecuteAsync(CancellationToken) instead. Scheduled for removal in Umbraco 19.")]
public override Task PerformExecuteAsync(object? state) => PerformExecuteAsync(CancellationToken.None);

/// <inheritdoc />
public override async Task StartAsync(CancellationToken cancellationToken)
{
var startingNotification = new Notifications.RecurringBackgroundJobStartingNotification(_job, new EventMessages());
await _eventAggregator.PublishAsync(startingNotification);
EventMessages eventMessages = _eventMessagesFactory.Get();
var startingNotification = new RecurringBackgroundJobStartingNotification(_job, eventMessages);
await _eventAggregator.PublishAsync(startingNotification, cancellationToken);

await base.StartAsync(cancellationToken);

await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobStartedNotification(_job, new EventMessages()).WithStateFrom(startingNotification));

await _eventAggregator.PublishAsync(new RecurringBackgroundJobStartedNotification(_job, eventMessages).WithStateFrom(startingNotification), cancellationToken);
}

/// <summary>
/// Asynchronously stops the recurring background job service, publishing notifications before and after stopping.
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A task that represents the asynchronous stop operation.</returns>
/// <inheritdoc />
public override async Task StopAsync(CancellationToken cancellationToken)
{
var stoppingNotification = new Notifications.RecurringBackgroundJobStoppingNotification(_job, new EventMessages());
await _eventAggregator.PublishAsync(stoppingNotification);
EventMessages eventMessages = _eventMessagesFactory.Get();
var stoppingNotification = new RecurringBackgroundJobStoppingNotification(_job, eventMessages);
await _eventAggregator.PublishAsync(stoppingNotification, cancellationToken);

await base.StopAsync(cancellationToken);

await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobStoppedNotification(_job, new EventMessages()).WithStateFrom(stoppingNotification));
await _eventAggregator.PublishAsync(new RecurringBackgroundJobStoppedNotification(_job, eventMessages).WithStateFrom(stoppingNotification), cancellationToken);
}
}
Loading
Loading