Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
62 changes: 35 additions & 27 deletions src/Umbraco.Infrastructure/BackgroundJobs/DelayCalculator.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -14,37 +9,48 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs
public class DelayCalculator
{
/// <summary>
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optonal
/// configuration for the first run time is available.
/// Determines the delay before the first run of a recurring task, using a <see cref="TimeProvider" /> for the current time.
/// </summary>
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser"/></param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser" />.</param>
/// <param name="logger">The logger.</param>
/// <param name="timeProvider">The time provider used to determine the current time.</param>
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
/// <returns>The delay before first running the recurring task.</returns>
public static TimeSpan GetDelay(
string firstRunTime,
ICronTabParser cronTabParser,
ILogger logger,
TimeSpan defaultDelay) => GetDelay(firstRunTime, cronTabParser, logger, DateTime.Now, defaultDelay);
/// <returns>
/// The delay before first running the recurring task.
/// </returns>
public static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, TimeProvider timeProvider, TimeSpan defaultDelay)
=> GetDelay(firstRunTime, cronTabParser, logger, timeProvider.GetLocalNow().DateTime, defaultDelay);

/// <summary>
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optonal
/// configuration for the first run time is available.
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optional configuration for the first run time is available.
/// </summary>
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser"/></param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser" />.</param>
/// <param name="logger">The logger.</param>
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
/// <returns>
/// The delay before first running the recurring task.
/// </returns>
[Obsolete("Use the overload accepting TimeProvider. Scheduled for removal in Umbraco 19.")]
public static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, TimeSpan defaultDelay)
=> GetDelay(firstRunTime, cronTabParser, logger, DateTime.Now, defaultDelay);

/// <summary>
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optional configuration for the first run time is available.
/// </summary>
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser" />.</param>
/// <param name="logger">The logger.</param>
/// <param name="now">The current datetime.</param>
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
/// <returns>The delay before first running the recurring task.</returns>
/// <remarks>Internal to expose for unit tests.</remarks>
internal static TimeSpan GetDelay(
string firstRunTime,
ICronTabParser cronTabParser,
ILogger logger,
DateTime now,
TimeSpan defaultDelay)
/// <returns>
/// The delay before first running the recurring task.
/// </returns>
/// <remarks>
/// Internal to expose for unit tests.
/// </remarks>
internal static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, DateTime now, TimeSpan defaultDelay)
{
// If first run time not set, start with just small delay after application start.
if (string.IsNullOrEmpty(firstRunTime))
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,38 +1,89 @@
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>
/// <remarks>
/// Set to <see cref="Timeout.InfiniteTimeSpan" /> to (temporarily) disable automatic scheduling and turn the job into a manually triggered one (via <see cref="IRecurringBackgroundJobTrigger{TJob}" />). Raise <see cref="PeriodChanged" /> to switch back to a finite period at runtime.
/// </remarks>
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>
/// <value>
/// The delay.
/// </value>
/// <remarks>
/// Set to <see cref="Timeout.InfiniteTimeSpan" /> to skip the automatic first run entirely; the first execution then only occurs when manually triggered via <see cref="IRecurringBackgroundJobTrigger{TJob}" />.
/// </remarks>
TimeSpan Delay => RecurringBackgroundJobBase.DefaultDelay; // TODO (V19): Remove the default implementation

/// <summary>
/// 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).
/// </summary>
TimeSpan Delay { get => DefaultDelay; }
/// <value>
/// The ignored delay.
/// </value>
/// <remarks>
/// This back-off prevents tight looping when <see cref="Period" /> is short (or <see cref="TimeSpan.Zero" />) and an execution is skipped without invoking <see cref="RunJobAsync(CancellationToken)" />.
/// Set to <see cref="Timeout.InfiniteTimeSpan" /> 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).
/// </remarks>
TimeSpan IgnoredDelay => RecurringBackgroundJobBase.DefaultIgnoredDelay; // TODO (V19): Remove the default implementation

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

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

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

/// <summary>
/// Runs the background job with cancellation support.
/// </summary>
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
/// <returns>
/// A task representing the asynchronous operation.
/// </returns>
Task RunJobAsync(CancellationToken cancellationToken)
#pragma warning disable CS0618 // Type or member is obsolete
=> RunJobAsync(); // TODO (V19): Remove the default implementation when RunJobAsync() is removed
#pragma warning restore CS0618 // Type or member is obsolete
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Provides methods to signal a specific recurring background job to execute immediately.
/// </summary>
/// <typeparam name="TJob">The type of the recurring background job to trigger, as registered via <see cref="ServiceCollectionExtensions.AddRecurringBackgroundJob{TJob}(IServiceCollection)" />.</typeparam>
public interface IRecurringBackgroundJobTrigger<TJob>
where TJob : class, ITriggerableRecurringBackgroundJob
Comment thread
AndyButland marked this conversation as resolved.
{
/// <summary>
/// Signals the background loop to execute immediately.
/// After the triggered execution, the original schedule is kept.
/// </summary>
/// <returns>
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
/// </returns>
/// <seealso cref="NextExecutionStrategy.None" />
bool TriggerExecution();

/// <summary>
/// Signals the background loop to execute immediately, with the specified strategy for determining the next execution after the triggered one completes.
/// </summary>
/// <param name="strategy">Controls the delay after the triggered execution.</param>
/// <returns>
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
/// </returns>
bool TriggerExecution(NextExecutionStrategy strategy);

/// <summary>
/// Signals the background loop to execute immediately.
/// After the triggered execution, the next execution is scheduled after the specified delay (measured from execution start; execution time is subtracted to prevent drift).
/// </summary>
/// <param name="nextDelay">The target interval from execution start to the next execution. Execution time is subtracted to prevent drift.</param>
/// <returns>
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
/// </returns>
bool TriggerExecution(TimeSpan nextDelay);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.

namespace Umbraco.Cms.Infrastructure.BackgroundJobs;

/// <summary>
/// Marker interface for recurring background jobs that support being triggered manually.
/// Only jobs implementing this interface can be triggered via <see cref="IRecurringBackgroundJobTrigger{TJob}" />.
/// </summary>
public interface ITriggerableRecurringBackgroundJob : IRecurringBackgroundJob
{ }
29 changes: 10 additions & 19 deletions src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ReportSiteJob.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -12,33 +10,23 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
/// <summary>
/// Represents a background job that collects and reports information about the current Umbraco site, typically for analytics, diagnostics, or telemetry purposes.
/// </summary>
public class ReportSiteJob : IRecurringBackgroundJob
public class ReportSiteJob : RecurringBackgroundJobBase
{
/// <summary>
/// Gets the period at which the report site job runs.
/// </summary>
public TimeSpan Period => TimeSpan.FromDays(1);
public override TimeSpan Period => TimeSpan.FromDays(1);

/// <summary>
/// Gets the time interval to wait between executions of the <see cref="ReportSiteJob"/>.
/// The delay is set to 5 minutes.
/// </summary>
public TimeSpan Delay => TimeSpan.FromMinutes(5);
public override TimeSpan Delay => TimeSpan.FromMinutes(5);

/// <summary>
/// Gets an array containing all possible values of the <see cref="ServerRole"/> enumeration.
/// </summary>
public ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();

/// <summary>
/// Event that is triggered when the reporting period for the site job is changed.
/// </summary>
/// <remarks>No-op event as the period never changes on this job</remarks>
public event EventHandler PeriodChanged
{
add { }
remove { }
}
public override ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();

private readonly ILogger<ReportSiteJob> _logger;
private readonly ITelemetryService _telemetryService;
Expand Down Expand Up @@ -67,8 +55,11 @@ public ReportSiteJob(
/// <summary>
/// Executes the background job that sends the anonymous site ID to the telemetry service.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task RunJobAsync()
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
/// <returns>
/// A task that represents the asynchronous operation.
/// </returns>
public override async Task RunJobAsync(CancellationToken cancellationToken)
{
TelemetryReportData? telemetryReportData = await _telemetryService.GetTelemetryReportDataAsync().ConfigureAwait(false);
if (telemetryReportData is null)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -13,28 +12,24 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
/// <summary>
/// Implements periodic database instruction processing as a hosted service.
/// </summary>
public class InstructionProcessJob : IRecurringBackgroundJob
public class InstructionProcessJob : RecurringBackgroundJobBase
{
private readonly TimeSpan _period;

/// <summary>
/// Gets the interval between executions of the instruction process job.
/// </summary>
public TimeSpan Period { get; }
public override TimeSpan Period => _period;

/// <summary>
/// Gets the delay time before the job is executed. The delay is fixed at one minute.
/// </summary>
public TimeSpan Delay { get => TimeSpan.FromMinutes(1); }
public override TimeSpan Delay => TimeSpan.FromMinutes(1);

/// <summary>
/// Gets an array containing all possible values of the <see cref="ServerRole"/> enumeration.
/// </summary>
public ServerRole[] ServerRoles { get => Enum.GetValues<ServerRole>(); }

/// <summary>
/// Event that is raised when the execution period of the <see cref="InstructionProcessJob"/> is changed.
/// </summary>
/// <remarks>No-op event as the period never changes on this job</remarks>
public event EventHandler PeriodChanged { add { } remove { } }
public override ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();

private readonly ILogger<InstructionProcessJob> _logger;
private readonly IServerMessenger _messenger;
Expand All @@ -53,15 +48,18 @@ public InstructionProcessJob(
_messenger = messenger;
_logger = logger;

Period = globalSettings.Value.DatabaseServerMessenger.TimeBetweenSyncOperations;
_period = globalSettings.Value.DatabaseServerMessenger.TimeBetweenSyncOperations;
}

/// <summary>
/// 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.
/// </summary>
/// <returns>A completed task representing the asynchronous operation.</returns>
public Task RunJobAsync()
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
/// <returns>
/// A completed task representing the asynchronous operation.
/// </returns>
public override Task RunJobAsync(CancellationToken cancellationToken)
{
try
{
Expand Down
Loading
Loading