Files
Umbraco-CMS/src/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBase.cs
Peter bb1c36f591 Making method ExecuteAsync virtual. (#16496)
Co-authored-by: Kvyatkovsky, Petr <pkvyatkovsky@qulix.com>
(cherry picked from commit 3a9ef3810b)
2024-08-15 11:15:24 +02:00

179 lines
6.8 KiB
C#

// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration;
namespace Umbraco.Cms.Infrastructure.HostedServices;
/// <summary>
/// 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&amp;tabs=visual-studio#timed-background-tasks"/>.
/// </remarks>
public abstract class RecurringHostedServiceBase : IHostedService, IDisposable
{
/// <summary>
/// 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;
/// <summary>
/// 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)
{
_logger = logger;
_period = period;
_delay = delay;
}
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <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.
/// </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="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);
/// <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.
/// </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)
{
// If first run time not set, start with just small delay after application start.
if (string.IsNullOrEmpty(firstRunTime))
{
return defaultDelay;
}
// If first run time not a valid cron tab, log, and revert to small delay after application start.
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;
return delay < defaultDelay
? defaultDelay
: delay;
}
/// <inheritdoc />
public virtual Task StartAsync(CancellationToken cancellationToken)
{
using (!ExecutionContext.IsFlowSuppressed() ? (IDisposable)ExecutionContext.SuppressFlow() : null)
{
_timer = new Timer(ExecuteAsync, null, _delay, _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.
/// </summary>
/// <param name="state">The task state.</param>
public virtual async void ExecuteAsync(object? state)
{
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
{
// Resume now that the task is complete - Note we use period in both because we don't want to execute again after the delay.
// So first execution is after _delay, and the we wait _period between each
_timer?.Change(_period, _period);
}
}
public abstract Task PerformExecuteAsync(object? state);
/// <summary>
/// Change the period between operations.
/// </summary>
/// <param name="newPeriod">The new period between tasks</param>
protected void ChangePeriod(TimeSpan newPeriod) => _period = newPeriod;
protected virtual void Dispose(bool disposing)
{
if (!_disposedValue)
{
if (disposing)
{
_timer?.Dispose();
}
_disposedValue = true;
}
}
}