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