diff --git a/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs b/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs index 0139bb61ce91..82cc61dc185b 100644 --- a/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs +++ b/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs @@ -9,8 +9,8 @@ using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; -using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; -using Umbraco.Extensions; +using Umbraco.Cms.Infrastructure.BackgroundJobs; +using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs; namespace Umbraco.Cms.Api.Common.DependencyInjection; @@ -139,7 +139,7 @@ private static void ConfigureOpenIddict(IUmbracoBuilder builder) }); }); - builder.Services.AddRecurringBackgroundJob(); + builder.Services.AddSingleton(); builder.Services.ConfigureOptions(); } } diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs index b97edf518879..5ba6e80bd0e9 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs @@ -30,7 +30,7 @@ public static IUmbracoBuilder .AddMembersIdentity() .AddUmbracoProfiler() .AddMvcAndRazor(configureMvc) - .AddRecurringBackgroundJobs() + .AddBackgroundJobs() .AddUmbracoHybridCache() .AddDistributedCache() .AddCoreNotifications(); diff --git a/src/Umbraco.Core/Configuration/Models/DistributedJobSettings.cs b/src/Umbraco.Core/Configuration/Models/DistributedJobSettings.cs new file mode 100644 index 000000000000..49baff36b496 --- /dev/null +++ b/src/Umbraco.Core/Configuration/Models/DistributedJobSettings.cs @@ -0,0 +1,25 @@ +using System.ComponentModel; + +namespace Umbraco.Cms.Core.Configuration.Models; + +/// +/// Settings for distributed jobs. +/// +[UmbracoOptions(Constants.Configuration.ConfigDistributedJobs)] +public class DistributedJobSettings +{ + internal const string StaticPeriod = "00:00:10"; + internal const string StaticDelay = "00:01:00"; + + /// + /// Gets or sets a value for the period of checking if there are any runnable distributed jobs. + /// + [DefaultValue(StaticPeriod)] + public TimeSpan Period { get; set; } = TimeSpan.Parse(StaticPeriod); + + /// + /// Gets or sets a value for the delay of when to start checking for distributed jobs. + /// + [DefaultValue(StaticDelay)] + public TimeSpan Delay { get; set; } = TimeSpan.Parse(StaticDelay); +} diff --git a/src/Umbraco.Core/Constants-Configuration.cs b/src/Umbraco.Core/Constants-Configuration.cs index 10f8e6f8cc3e..57b0d543a67a 100644 --- a/src/Umbraco.Core/Constants-Configuration.cs +++ b/src/Umbraco.Core/Constants-Configuration.cs @@ -65,6 +65,7 @@ public static class Configuration public const string ConfigWebhook = ConfigPrefix + "Webhook"; public const string ConfigWebhookPayloadType = ConfigWebhook + ":PayloadType"; public const string ConfigCache = ConfigPrefix + "Cache"; + public const string ConfigDistributedJobs = ConfigPrefix + "DistributedJobs"; public static class NamedOptions { diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs index db3c8ef2b34c..84462dda981b 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs @@ -87,7 +87,8 @@ public static IUmbracoBuilder AddConfiguration(this IUmbracoBuilder builder) .AddUmbracoOptions() .AddUmbracoOptions() .AddUmbracoOptions() - .AddUmbracoOptions(); + .AddUmbracoOptions() + .AddUmbracoOptions(); // Configure connection string and ensure it's updated when the configuration changes builder.Services.AddSingleton, ConfigureConnectionStrings>(); diff --git a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs index cfa564ca43ef..354332413d64 100644 --- a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs +++ b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs @@ -103,8 +103,8 @@ public static class Tables public const string Webhook2Headers = Webhook + "2Headers"; public const string WebhookLog = Webhook + "Log"; public const string WebhookRequest = Webhook + "Request"; - public const string LongRunningOperation = TableNamePrefix + "LongRunningOperation"; + public const string DistributedJob = TableNamePrefix + "DistributedJob"; } } } diff --git a/src/Umbraco.Core/Persistence/Constants-Locks.cs b/src/Umbraco.Core/Persistence/Constants-Locks.cs index cc6c5d48538e..2a59a9c3c1f0 100644 --- a/src/Umbraco.Core/Persistence/Constants-Locks.cs +++ b/src/Umbraco.Core/Persistence/Constants-Locks.cs @@ -90,5 +90,10 @@ public static class Locks /// All document URLs. /// public const int DocumentUrls = -345; + + /// + /// All distributed jobs. + /// + public const int DistributedJobs = -347; } } diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/DistributedBackgroundJobHostedService.cs b/src/Umbraco.Infrastructure/BackgroundJobs/DistributedBackgroundJobHostedService.cs new file mode 100644 index 000000000000..01711d64ef2f --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/DistributedBackgroundJobHostedService.cs @@ -0,0 +1,103 @@ +using System.Diagnostics; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Services; + +namespace Umbraco.Cms.Infrastructure.BackgroundJobs; + +/// +/// A hosted service that checks for any runnable distributed background jobs on a timer. +/// +public class DistributedBackgroundJobHostedService : BackgroundService +{ + private readonly ILogger _logger; + private readonly IRuntimeState _runtimeState; + private readonly IDistributedJobService _distributedJobService; + private DistributedJobSettings _distributedJobSettings; + + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + /// + public DistributedBackgroundJobHostedService( + ILogger logger, + IRuntimeState runtimeState, + IDistributedJobService distributedJobService, + IOptionsMonitor distributedJobSettings) + { + _logger = logger; + _runtimeState = runtimeState; + _distributedJobService = distributedJobService; + _distributedJobSettings = distributedJobSettings.CurrentValue; + distributedJobSettings.OnChange(options => + { + _distributedJobSettings = options; + }); + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.Delay(_distributedJobSettings.Delay, stoppingToken); + + while (_runtimeState.Level != RuntimeLevel.Run) + { + await Task.Delay(_distributedJobSettings.Delay, stoppingToken); + } + + // Update all jobs, periods might have changed when restarting. + await _distributedJobService.EnsureJobsAsync(); + + using PeriodicTimer timer = new(_distributedJobSettings.Period); + + try + { + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + await RunRunnableJob(); + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("Timed Hosted Service is stopping."); + } + } + + private async Task RunRunnableJob() + { + IDistributedBackgroundJob? job = await _distributedJobService.TryTakeRunnableAsync(); + + if (job is null) + { + // No runnable jobs for now, return + return; + } + + try + { + await job.ExecuteAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "An exception occurred while running distributed background job '{JobName}'.", job.Name); + } + finally + { + try + { + await _distributedJobService.FinishAsync(job.Name); + } + catch (Exception ex) + { + _logger.LogError(ex, "An exception occurred while finishing distributed background job '{JobName}'.", job.Name); + } + } + } +} diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/IDistributedBackgroundJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/IDistributedBackgroundJob.cs new file mode 100644 index 000000000000..ffff45d6ffeb --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/IDistributedBackgroundJob.cs @@ -0,0 +1,23 @@ +namespace Umbraco.Cms.Infrastructure.BackgroundJobs; + +/// +/// A background job that will be executed by an available server. With a single server setup this will always be the same. +/// With a load balanced setup, the executing server might change every time this needs to be executed. +/// +public interface IDistributedBackgroundJob +{ + /// + /// Name of the job. + /// + string Name { get; } + + /// + /// Timespan representing how often the task should recur. + /// + TimeSpan Period { get; } + + /// + /// Run the job. + /// + Task ExecuteAsync(); +} diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/CacheInstructionsPruningJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/CacheInstructionsPruningJob.cs similarity index 86% rename from src/Umbraco.Infrastructure/BackgroundJobs/Jobs/CacheInstructionsPruningJob.cs rename to src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/CacheInstructionsPruningJob.cs index fca52f1581b8..d590dfab6697 100644 --- a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/CacheInstructionsPruningJob.cs +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/CacheInstructionsPruningJob.cs @@ -2,14 +2,13 @@ using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; -using Umbraco.Cms.Infrastructure.Scoping; -namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs; /// /// A background job that prunes cache instructions from the database. /// -public class CacheInstructionsPruningJob : IRecurringBackgroundJob +internal class CacheInstructionsPruningJob : IDistributedBackgroundJob { private readonly IOptions _globalSettings; private readonly ICacheInstructionRepository _cacheInstructionRepository; @@ -36,18 +35,13 @@ public CacheInstructionsPruningJob( Period = globalSettings.Value.DatabaseServerMessenger.TimeBetweenPruneOperations; } - /// - public event EventHandler PeriodChanged - { - add { } - remove { } - } + public string Name => "CacheInstructionsPruningJob"; /// public TimeSpan Period { get; } /// - public Task RunJobAsync() + public Task ExecuteAsync() { DateTimeOffset pruneDate = _timeProvider.GetUtcNow() - _globalSettings.Value.DatabaseServerMessenger.TimeToRetainInstructions; using (ICoreScope scope = _scopeProvider.CreateCoreScope()) diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ContentVersionCleanupJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/ContentVersionCleanupJob.cs similarity index 80% rename from src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ContentVersionCleanupJob.cs rename to src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/ContentVersionCleanupJob.cs index b5d826bbe886..156fa403d756 100644 --- a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ContentVersionCleanupJob.cs +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/ContentVersionCleanupJob.cs @@ -1,24 +1,21 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.Runtime; using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Core.Sync; -using Umbraco.Cms.Infrastructure.BackgroundJobs; -namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs; /// /// Recurring hosted service that executes the content history cleanup. /// -public class ContentVersionCleanupJob : IRecurringBackgroundJob +internal class ContentVersionCleanupJob : IDistributedBackgroundJob { + /// + public string Name => "ContentVersionCleanupJob"; + /// public TimeSpan Period { get => TimeSpan.FromHours(1); } - // No-op event as the period never changes on this job - public event EventHandler PeriodChanged { add { } remove { } } private readonly ILogger _logger; private readonly IContentVersionService _service; @@ -39,7 +36,7 @@ public ContentVersionCleanupJob( } /// - public Task RunJobAsync() + public Task ExecuteAsync() { // Globally disabled by feature flag if (!_settingsMonitor.CurrentValue.ContentVersionCleanupPolicy.EnableCleanup) diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/HealthCheckNotifierJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/HealthCheckNotifierJob.cs similarity index 82% rename from src/Umbraco.Infrastructure/BackgroundJobs/Jobs/HealthCheckNotifierJob.cs rename to src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/HealthCheckNotifierJob.cs index c79a1a0866b7..20b8565bd599 100644 --- a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/HealthCheckNotifierJob.cs +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/HealthCheckNotifierJob.cs @@ -1,9 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Umbraco.Cms.Core.Configuration; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.HealthChecks; @@ -12,25 +10,20 @@ using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Scoping; -namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs; /// /// Hosted service implementation for recurring health check notifications. /// -public class HealthCheckNotifierJob : IRecurringBackgroundJob +internal class HealthCheckNotifierJob : IDistributedBackgroundJob { - public TimeSpan Period { get; private set; } - public TimeSpan Delay { get; private set; } + /// + public string Name => "HealthCheckNotifierJob"; - private event EventHandler? _periodChanged; - public event EventHandler PeriodChanged - { - add { _periodChanged += value; } - remove { _periodChanged -= value; } - } + /// + public TimeSpan Period { get; private set; } private readonly HealthCheckCollection _healthChecks; - private readonly ILogger _logger; private readonly HealthCheckNotificationMethodCollection _notifications; private readonly IProfilingLogger _profilingLogger; private readonly IEventAggregator _eventAggregator; @@ -53,32 +46,28 @@ public HealthCheckNotifierJob( HealthCheckCollection healthChecks, HealthCheckNotificationMethodCollection notifications, ICoreScopeProvider scopeProvider, - ILogger logger, IProfilingLogger profilingLogger, - ICronTabParser cronTabParser, IEventAggregator eventAggregator) { _healthChecksSettings = healthChecksSettings.CurrentValue; _healthChecks = healthChecks; _notifications = notifications; _scopeProvider = scopeProvider; - _logger = logger; _profilingLogger = profilingLogger; _eventAggregator = eventAggregator; Period = healthChecksSettings.CurrentValue.Notification.Period; - Delay = DelayCalculator.GetDelay(healthChecksSettings.CurrentValue.Notification.FirstRunTime, cronTabParser, logger, TimeSpan.FromMinutes(3)); healthChecksSettings.OnChange(x => { _healthChecksSettings = x; Period = x.Notification.Period; - _periodChanged?.Invoke(this, EventArgs.Empty); }); } - public async Task RunJobAsync() + /// + public async Task ExecuteAsync() { if (_healthChecksSettings.Notification.Enabled == false) { diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/LogScrubberJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/LogScrubberJob.cs similarity index 76% rename from src/Umbraco.Infrastructure/BackgroundJobs/Jobs/LogScrubberJob.cs rename to src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/LogScrubberJob.cs index 725c66c2f8bb..6582fde3f8c1 100644 --- a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/LogScrubberJob.cs +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/LogScrubberJob.cs @@ -1,17 +1,13 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Logging; -using Umbraco.Cms.Core.Runtime; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Core.Sync; -namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs; /// /// Log scrubbing hosted service. @@ -19,18 +15,18 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; /// /// Will only run on non-replica servers. /// -public class LogScrubberJob : IRecurringBackgroundJob +internal class LogScrubberJob : IDistributedBackgroundJob { private readonly IAuditService _auditService; - private readonly ILogger _logger; private readonly IProfilingLogger _profilingLogger; private readonly ICoreScopeProvider _scopeProvider; private LoggingSettings _settings; - public TimeSpan Period => TimeSpan.FromHours(4); + /// + public string Name => "LogScrubberJob"; - // No-op event as the period never changes on this job - public event EventHandler PeriodChanged { add { } remove { } } + /// + public TimeSpan Period => TimeSpan.FromHours(4); /// /// Initializes a new instance of the class. @@ -44,21 +40,20 @@ public LogScrubberJob( IAuditService auditService, IOptionsMonitor settings, ICoreScopeProvider scopeProvider, - ILogger logger, IProfilingLogger profilingLogger) { _auditService = auditService; _settings = settings.CurrentValue; _scopeProvider = scopeProvider; - _logger = logger; _profilingLogger = profilingLogger; settings.OnChange(x => _settings = x); } - public async Task RunJobAsync() + /// + public async Task ExecuteAsync() { // Ensure we use an explicit scope since we are running on a background thread. - using (ICoreScope scope = _scopeProvider.CreateCoreScope()) + using ICoreScope scope = _scopeProvider.CreateCoreScope(); using (_profilingLogger.DebugDuration("Log scrubbing executing", "Log scrubbing complete")) { await _auditService.CleanLogsAsync((int)_settings.MaxLogAge.TotalMinutes); diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/LongRunningOperationsCleanupJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/LongRunningOperationsCleanupJob.cs similarity index 84% rename from src/Umbraco.Infrastructure/BackgroundJobs/Jobs/LongRunningOperationsCleanupJob.cs rename to src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/LongRunningOperationsCleanupJob.cs index 46d139b08bce..d48f3c434e53 100644 --- a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/LongRunningOperationsCleanupJob.cs +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/LongRunningOperationsCleanupJob.cs @@ -3,12 +3,12 @@ using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; -namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs; /// /// Cleans up long-running operations that have exceeded a specified age. /// -public class LongRunningOperationsCleanupJob : IRecurringBackgroundJob +internal class LongRunningOperationsCleanupJob : IDistributedBackgroundJob { private readonly ICoreScopeProvider _scopeProvider; private readonly ILongRunningOperationRepository _longRunningOperationRepository; @@ -36,20 +36,13 @@ public LongRunningOperationsCleanupJob( } /// - public event EventHandler? PeriodChanged - { - add { } - remove { } - } + public string Name => "LongRunningOperationsCleanupJob"; /// public TimeSpan Period { get; } - /// - public TimeSpan Delay { get; } = TimeSpan.FromSeconds(10); - /// - public async Task RunJobAsync() + public async Task ExecuteAsync() { using ICoreScope scope = _scopeProvider.CreateCoreScope(); await _longRunningOperationRepository.CleanOperationsAsync(_timeProvider.GetUtcNow() - _maxEntryAge); diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/OpenIddictCleanupJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/OpenIddictCleanupJob.cs similarity index 72% rename from src/Umbraco.Infrastructure/BackgroundJobs/Jobs/OpenIddictCleanupJob.cs rename to src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/OpenIddictCleanupJob.cs index 6c93d4a64976..a38e939d0029 100644 --- a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/OpenIddictCleanupJob.cs +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/OpenIddictCleanupJob.cs @@ -1,18 +1,20 @@ -using System.Configuration; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OpenIddict.Abstractions; -namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs; -// port of the OpenIddict Quartz job for cleaning up - see https://github.com/openiddict/openiddict-core/tree/dev/src/OpenIddict.Quartz -public class OpenIddictCleanupJob : IRecurringBackgroundJob + +/// +/// Port of the OpenIddict Quartz job for cleaning up - see https://github.com/openiddict/openiddict-core/tree/dev/src/OpenIddict.Quartz +/// +public class OpenIddictCleanupJob : IDistributedBackgroundJob { - public TimeSpan Period { get => TimeSpan.FromHours(1); } - public TimeSpan Delay { get => TimeSpan.FromMinutes(5); } + /// + public string Name => "OpenIddictCleanupJob"; - // No-op event as the period never changes on this job - public event EventHandler PeriodChanged { add { } remove { } } + /// + public TimeSpan Period => TimeSpan.FromHours(1); // keep tokens and authorizations in the database for 7 days @@ -22,13 +24,19 @@ public event EventHandler PeriodChanged { add { } remove { } } private readonly ILogger _logger; private readonly IServiceProvider _provider; + /// + /// Initializes a new instance of the class. + /// + /// + /// public OpenIddictCleanupJob(ILogger logger, IServiceProvider provider) { _logger = logger; _provider = provider; } - public async Task RunJobAsync() + /// + public async Task ExecuteAsync() { // hosted services are registered as singletons, but this particular one consumes scoped services... so // we have to fetch the service dependencies manually using a new scope per invocation. diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ScheduledPublishingJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/ScheduledPublishingJob.cs similarity index 91% rename from src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ScheduledPublishingJob.cs rename to src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/ScheduledPublishingJob.cs index 6ce455a7a8c6..2467524b3d7f 100644 --- a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ScheduledPublishingJob.cs +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/ScheduledPublishingJob.cs @@ -3,13 +3,12 @@ using Microsoft.Extensions.Logging; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Runtime; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Sync; using Umbraco.Cms.Core.Web; -namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs; /// /// Hosted service implementation for scheduled publishing feature. @@ -17,11 +16,13 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; /// /// Runs only on non-replica servers. /// -public class ScheduledPublishingJob : IRecurringBackgroundJob +internal class ScheduledPublishingJob : IDistributedBackgroundJob { - public TimeSpan Period { get => TimeSpan.FromMinutes(1); } - // No-op event as the period never changes on this job - public event EventHandler PeriodChanged { add { } remove { } } + /// + public string Name => "ScheduledPublishingJob"; + + /// + public TimeSpan Period => TimeSpan.FromMinutes(1); private readonly IContentService _contentService; @@ -50,7 +51,8 @@ public ScheduledPublishingJob( _timeProvider = timeProvider; } - public Task RunJobAsync() + /// + public Task ExecuteAsync() { if (Suspendable.ScheduledPublishing.CanRun == false) { diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/TemporaryFileCleanupJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/TemporaryFileCleanupJob.cs similarity index 61% rename from src/Umbraco.Infrastructure/BackgroundJobs/Jobs/TemporaryFileCleanupJob.cs rename to src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/TemporaryFileCleanupJob.cs index 0c4e331484ad..2cac593adc3c 100644 --- a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/TemporaryFileCleanupJob.cs +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/TemporaryFileCleanupJob.cs @@ -1,15 +1,18 @@ using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Services; -namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs; -public class TemporaryFileCleanupJob : IRecurringBackgroundJob +/// +/// Cleans up temporary media files. +/// +internal class TemporaryFileCleanupJob : IDistributedBackgroundJob { - public TimeSpan Period { get => TimeSpan.FromMinutes(5); } - public TimeSpan Delay { get => TimeSpan.FromMinutes(5); } + /// + public string Name => "TemporaryFileCleanupJob"; - // No-op event as the period never changes on this job - public event EventHandler PeriodChanged { add { } remove { } } + /// + public TimeSpan Period => TimeSpan.FromMinutes(5); private readonly ILogger _logger; private readonly ITemporaryFileService _service; @@ -26,11 +29,9 @@ public TemporaryFileCleanupJob( _service = temporaryFileService; } - /// - /// Runs the background task to send the anonymous ID - /// to telemetry service - /// - public async Task RunJobAsync() + + /// + public async Task ExecuteAsync() { var count = (await _service.CleanUpOldTempFiles()).Count(); diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/WebhookFiring.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/WebhookFiring.cs similarity index 85% rename from src/Umbraco.Infrastructure/BackgroundJobs/Jobs/WebhookFiring.cs rename to src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/WebhookFiring.cs index 6ff87ff3cffa..2b4938d44286 100644 --- a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/WebhookFiring.cs +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/WebhookFiring.cs @@ -8,9 +8,12 @@ using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; -namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs; -public class WebhookFiring : IRecurringBackgroundJob +/// +/// Fires pending webhooks. +/// +internal class WebhookFiring : IDistributedBackgroundJob { private readonly ILogger _logger; private readonly IWebhookRequestService _webhookRequestService; @@ -21,13 +24,23 @@ public class WebhookFiring : IRecurringBackgroundJob private readonly IHttpClientFactory _httpClientFactory; private WebhookSettings _webhookSettings; - public TimeSpan Period => _webhookSettings.Period; - - public TimeSpan Delay { get; } = TimeSpan.FromSeconds(20); + /// + public string Name => "WebhookFiring"; - // No-op event as the period never changes on this job - public event EventHandler PeriodChanged { add { } remove { } } + /// + public TimeSpan Period => _webhookSettings.Period; + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + /// + /// + /// + /// + /// public WebhookFiring( ILogger logger, IWebhookRequestService webhookRequestService, @@ -49,7 +62,8 @@ public WebhookFiring( webhookSettings.OnChange(x => _webhookSettings = x); } - public async Task RunJobAsync() + /// + public async Task ExecuteAsync() { if (_webhookSettings.Enabled is false) { diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/WebhookLoggingCleanup.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/WebhookLoggingCleanup.cs similarity index 80% rename from src/Umbraco.Infrastructure/BackgroundJobs/Jobs/WebhookLoggingCleanup.cs rename to src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/WebhookLoggingCleanup.cs index 8a76898923d6..766188368e46 100644 --- a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/WebhookLoggingCleanup.cs +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/DistributedJobs/WebhookLoggingCleanup.cs @@ -7,18 +7,25 @@ using Umbraco.Cms.Core.Scoping; using Umbraco.Extensions; -namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs; /// /// Daily background job that removes all webhook log data older than x days as defined by /// -public class WebhookLoggingCleanup : IRecurringBackgroundJob +internal class WebhookLoggingCleanup : IDistributedBackgroundJob { private readonly ILogger _logger; private readonly WebhookSettings _webhookSettings; private readonly IWebhookLogRepository _webhookLogRepository; private readonly ICoreScopeProvider _coreScopeProvider; + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + /// public WebhookLoggingCleanup(ILogger logger, IOptionsMonitor webhookSettings, IWebhookLogRepository webhookLogRepository, ICoreScopeProvider coreScopeProvider) { _logger = logger; @@ -28,20 +35,13 @@ public WebhookLoggingCleanup(ILogger logger, IOptionsMoni } /// - // No-op event as the period never changes on this job - public event EventHandler PeriodChanged - { - add { } remove { } - } + public string Name => "WebhookLoggingCleanup"; /// public TimeSpan Period => TimeSpan.FromDays(1); /// - public TimeSpan Delay { get; } = TimeSpan.FromSeconds(20); - - /// - public async Task RunJobAsync() + public async Task ExecuteAsync() { if (_webhookSettings.EnableLoggingCleanup is false) { diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.BackgroundJobs.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.BackgroundJobs.cs new file mode 100644 index 000000000000..f5e63b319350 --- /dev/null +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.BackgroundJobs.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services.Navigation; +using Umbraco.Cms.Infrastructure.BackgroundJobs; +using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; +using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs; +using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration; +using Umbraco.Cms.Infrastructure.HostedServices; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.DependencyInjection; + +public static partial class UmbracoBuilderExtensions +{ + /// + /// Add Umbraco background jobs + /// + public static IUmbracoBuilder AddBackgroundJobs(this IUmbracoBuilder builder) + { + // Add background jobs + builder.Services.AddRecurringBackgroundJob(); + builder.Services.AddRecurringBackgroundJob(); + builder.Services.AddRecurringBackgroundJob(); + builder.Services.AddRecurringBackgroundJob(); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(); + + builder.Services.AddSingleton(RecurringBackgroundJobHostedService.CreateHostedServiceFactory); + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + + return builder; + } +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs index 1b624aa5f618..903f01e49e07 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs @@ -83,6 +83,7 @@ internal static IUmbracoBuilder AddRepositories(this IUmbracoBuilder builder) builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); + builder.Services.AddUnique(); return builder; } diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs index 4a87fc2bc8cf..828d572466a2 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs @@ -84,6 +84,7 @@ internal static IUmbracoBuilder AddServices(this IUmbracoBuilder builder) builder.Services.TryAddTransient(); builder.Services.AddUnique(); builder.Services.AddUnique(); + builder.Services.AddUnique(); return builder; } diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs index 0b1417f69906..81a6b0440905 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs @@ -8,6 +8,7 @@ using Umbraco.Cms.Core.Configuration; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Infrastructure.BackgroundJobs; using Umbraco.Cms.Infrastructure.Migrations.Upgrade; using Umbraco.Cms.Infrastructure.Persistence; using Umbraco.Cms.Infrastructure.Persistence.Dtos; @@ -1058,6 +1059,8 @@ private void CreateLockData() _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.WebhookLogs, Name = "WebhookLogs" }); _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.LongRunningOperations, Name = "LongRunningOperations" }); _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.DocumentUrls, Name = "DocumentUrls" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.DistributedJobs, Name = "DistributedJobs" }); + } private void CreateContentTypeData() diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs index d454d97847ce..1818e9accaca 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs @@ -91,6 +91,7 @@ public class DatabaseSchemaCreator typeof(WebhookRequestDto), typeof(UserDataDto), typeof(LongRunningOperationDto), + typeof(DistributedJobDto), }; private readonly IUmbracoDatabase _database; diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index bafc3c4e853d..bbf3990807a6 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -133,5 +133,6 @@ protected virtual void DefinePlan() To("{EB1E50B7-CD5E-4B6B-B307-36237DD2C506}"); To("{1847C7FF-B021-44EB-BEB0-A77A4376A6F2}"); To("{7208B20D-6BFC-472E-9374-85EEA817B27D}"); + To("{263075BF-F18A-480D-92B4-4947D2EAB772}"); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/AddDistributedJobLock.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/AddDistributedJobLock.cs new file mode 100644 index 000000000000..30b6fb8f4266 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/AddDistributedJobLock.cs @@ -0,0 +1,51 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.BackgroundJobs; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_17_0_0; + +/// +/// Adds all the distributed jobs to the database. +/// +public class AddDistributedJobLock : AsyncMigrationBase +{ + private readonly IEnumerable _distributedBackgroundJobs; + + /// + /// Initializes a new instance of the class. + /// + /// + /// + public AddDistributedJobLock(IMigrationContext context, IEnumerable distributedBackgroundJobs) + : base(context) => _distributedBackgroundJobs = distributedBackgroundJobs; + + /// + protected override Task MigrateAsync() + { + if (!TableExists(Constants.DatabaseSchema.Tables.DistributedJob)) + { + Create.Table().Do(); + } + + if (!TableExists(Constants.DatabaseSchema.Tables.Lock)) + { + Create.Table().Do(); + } + + Sql sql = Database.SqlContext.Sql() + .Select() + .From() + .Where(x => x.Id == Constants.Locks.DistributedJobs); + + LockDto? existingLockDto = Database.FirstOrDefault(sql); + if (existingLockDto is null) + { + Database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.DistributedJobs, Name = "DistributedJobs" }); + } + + return Task.CompletedTask; + } +} diff --git a/src/Umbraco.Infrastructure/Models/DistributedBackgroundJobModel.cs b/src/Umbraco.Infrastructure/Models/DistributedBackgroundJobModel.cs new file mode 100644 index 000000000000..2ac0287ba86d --- /dev/null +++ b/src/Umbraco.Infrastructure/Models/DistributedBackgroundJobModel.cs @@ -0,0 +1,32 @@ +namespace Umbraco.Cms.Infrastructure.Models; + +/// +/// Model for distributed background jobs. +/// +public class DistributedBackgroundJobModel +{ + /// + /// Name of job. + /// + public required string Name { get; init; } + + /// + /// Period of job. + /// + public TimeSpan Period { get; set; } + + /// + /// Time of last run. + /// + public DateTime LastRun { get; set; } + + /// + /// If the job is running. + /// + public bool IsRunning { get; set; } + + /// + /// Time of last attempted run. + /// + public DateTime LastAttemptedRun { get; set; } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/DistributedJobDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/DistributedJobDto.cs new file mode 100644 index 000000000000..2f88eae5b2ca --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/DistributedJobDto.cs @@ -0,0 +1,36 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseModelDefinitions; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +[TableName(TableName)] +[PrimaryKey("id", AutoIncrement = true)] +[ExplicitColumns] +internal sealed class DistributedJobDto +{ + public const string TableName = Constants.DatabaseSchema.Tables.DistributedJob; + + [Column("id")] + [PrimaryKeyColumn(AutoIncrement = true)] + public int Id { get; set; } + + [Column("Name")] + [NullSetting(NullSetting = NullSettings.NotNull)] + public required string Name { get; set; } + + [Column("lastRun")] + [Constraint(Default = SystemMethods.CurrentUTCDateTime)] + public DateTime LastRun { get; set; } + + [Column("period")] + public long Period { get; set; } + + [Column("IsRunning")] + public bool IsRunning { get; set; } + + [Column("lastAttemptedRun")] + [Constraint(Default = SystemMethods.CurrentUTCDateTime)] + public DateTime LastAttemptedRun { get; set; } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/IDistributedJobRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/IDistributedJobRepository.cs new file mode 100644 index 000000000000..2566a5b9b989 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/IDistributedJobRepository.cs @@ -0,0 +1,36 @@ +using Umbraco.Cms.Infrastructure.Models; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories; + +/// +/// Defines a repository for managing distributed jobs. +/// +public interface IDistributedJobRepository +{ + /// + /// Gets a job by name. + /// + /// + DistributedBackgroundJobModel? GetByName(string jobName); + + /// + /// Gets all jobs. + /// + /// + IEnumerable GetAll(); + + /// + /// Updates a job. + /// + void Update(DistributedBackgroundJobModel distributedBackgroundJob); + + /// + /// Adds a job. + /// + void Add(DistributedBackgroundJobModel distributedBackgroundJob); + + /// + /// Deletes a job. + /// + void Delete(DistributedBackgroundJobModel distributedBackgroundJob); +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DistributedJobRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DistributedJobRepository.cs new file mode 100644 index 000000000000..6436a24d7bfc --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DistributedJobRepository.cs @@ -0,0 +1,109 @@ +using NPoco; +using Umbraco.Cms.Core.Exceptions; +using Umbraco.Cms.Infrastructure.Models; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; + +/// +internal class DistributedJobRepository(IScopeAccessor scopeAccessor) : IDistributedJobRepository +{ + /// + public DistributedBackgroundJobModel? GetByName(string jobName) + { + if (scopeAccessor.AmbientScope is null) + { + throw new InvalidOperationException("No scope, could not get distributed jobs"); + } + + Sql sql = scopeAccessor.AmbientScope.SqlContext.Sql() + .Select() + .From() + .Where(x => x.Name == jobName); + + DistributedJobDto? dto = scopeAccessor.AmbientScope.Database.FirstOrDefault(sql); + return dto is null ? null : MapFromDto(dto); + } + + /// + public IEnumerable GetAll() + { + if (scopeAccessor.AmbientScope is null) + { + throw new InvalidOperationException("No scope, could not get distributed jobs"); + } + + Sql sql = scopeAccessor.AmbientScope.SqlContext.Sql() + .Select() + .From(); + + IUmbracoDatabase database = scopeAccessor.AmbientScope.Database; + List jobs = database.Fetch(sql); + return jobs.Select(MapFromDto); + } + + /// + public void Update(DistributedBackgroundJobModel distributedBackgroundJob) + { + if (scopeAccessor.AmbientScope is null) + { + return; + } + + DistributedJobDto dto = MapToDto(distributedBackgroundJob); + + scopeAccessor.AmbientScope.Database.Update(dto); + } + + /// + public void Add(DistributedBackgroundJobModel distributedBackgroundJob) + { + if (scopeAccessor.AmbientScope is null) + { + throw new InvalidOperationException("No scope, could not add distributed job"); + } + + DistributedJobDto dto = MapToDto(distributedBackgroundJob); + + scopeAccessor.AmbientScope.Database.Insert(dto); + } + + /// + public void Delete(DistributedBackgroundJobModel distributedBackgroundJob) + { + if (scopeAccessor.AmbientScope is null) + { + throw new InvalidOperationException("No scope, could not delete distributed job"); + } + + DistributedJobDto dto = MapToDto(distributedBackgroundJob); + + int rowsAffected = scopeAccessor.AmbientScope.Database.Delete(dto); + if (rowsAffected == 0) + { + throw new InvalidOperationException("Could not delete distributed job, it may have already been deleted"); + } + } + + private DistributedJobDto MapToDto(DistributedBackgroundJobModel model) => + new() + { + Name = model.Name, + Period = model.Period.Ticks, + LastRun = model.LastRun, + IsRunning = model.IsRunning, + LastAttemptedRun = model.LastAttemptedRun, + }; + + private DistributedBackgroundJobModel MapFromDto(DistributedJobDto jobDto) => + new() + { + Name = jobDto.Name, + Period = TimeSpan.FromTicks(jobDto.Period), + LastRun = jobDto.LastRun, + IsRunning = jobDto.IsRunning, + LastAttemptedRun = jobDto.LastAttemptedRun, + }; +} diff --git a/src/Umbraco.Infrastructure/Services/IDistributedJobService.cs b/src/Umbraco.Infrastructure/Services/IDistributedJobService.cs new file mode 100644 index 000000000000..5932c380cfef --- /dev/null +++ b/src/Umbraco.Infrastructure/Services/IDistributedJobService.cs @@ -0,0 +1,35 @@ +using Umbraco.Cms.Infrastructure.BackgroundJobs; + +namespace Umbraco.Cms.Infrastructure.Services; + +/// +/// Service for managing distributed jobs. +/// +public interface IDistributedJobService +{ + /// + /// Attempts to claim a runnable distributed job for execution. + /// + /// + /// The claimed if available, or if no jobs are ready to run. + /// + Task TryTakeRunnableAsync(); + + /// + /// Finishes a job. + /// + Task FinishAsync(string jobName); + + /// + /// Ensures all distributed jobs are registered in the database on startup. + /// + /// + /// This method handles two scenarios: + /// + /// Fresh install: Adds all registered jobs to the database + /// Restart: Updates existing jobs where periods have changed and adds any new jobs + /// + /// Jobs that exist in the database but are no longer registered in code will be removed. + /// + Task EnsureJobsAsync(); +} diff --git a/src/Umbraco.Infrastructure/Services/Implement/DistributedJobService.cs b/src/Umbraco.Infrastructure/Services/Implement/DistributedJobService.cs new file mode 100644 index 000000000000..286567842605 --- /dev/null +++ b/src/Umbraco.Infrastructure/Services/Implement/DistributedJobService.cs @@ -0,0 +1,139 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Infrastructure.BackgroundJobs; +using Umbraco.Cms.Infrastructure.Models; +using Umbraco.Cms.Infrastructure.Persistence.Repositories; + +namespace Umbraco.Cms.Infrastructure.Services.Implement; + +/// +public class DistributedJobService : IDistributedJobService +{ + private readonly ICoreScopeProvider _coreScopeProvider; + private readonly IDistributedJobRepository _distributedJobRepository; + private readonly IEnumerable _distributedBackgroundJobs; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + /// + public DistributedJobService( + ICoreScopeProvider coreScopeProvider, + IDistributedJobRepository distributedJobRepository, + IEnumerable distributedBackgroundJobs, + ILogger logger) + { + _coreScopeProvider = coreScopeProvider; + _distributedJobRepository = distributedJobRepository; + _distributedBackgroundJobs = distributedBackgroundJobs; + _logger = logger; + } + + /// + public async Task TryTakeRunnableAsync() + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + + scope.EagerWriteLock(Constants.Locks.DistributedJobs); + + IEnumerable jobs = _distributedJobRepository.GetAll(); + DistributedBackgroundJobModel? job = jobs.FirstOrDefault(x => x.LastRun < DateTime.UtcNow - x.Period); + + if (job is null) + { + // No runnable jobs for now. + return null; + } + + job.LastAttemptedRun = DateTime.UtcNow; + job.IsRunning = true; + _distributedJobRepository.Update(job); + + IDistributedBackgroundJob? distributedJob = _distributedBackgroundJobs.FirstOrDefault(x => x.Name == job.Name); + + if (distributedJob is null) + { + _logger.LogWarning("Could not find a distributed job with the name '{JobName}'", job.Name); + } + + scope.Complete(); + + return distributedJob; + } + + /// + public async Task FinishAsync(string jobName) + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + + scope.EagerWriteLock(Constants.Locks.DistributedJobs); + DistributedBackgroundJobModel? job = _distributedJobRepository.GetByName(jobName); + + if (job is null) + { + _logger.LogWarning("Could not finish a distributed job with the name '{JobName}'", jobName); + return; + } + + DateTime currentDateTime = DateTime.UtcNow; + job.LastAttemptedRun = currentDateTime; + job.LastRun = currentDateTime; + job.IsRunning = false; + _distributedJobRepository.Update(job); + + scope.Complete(); + } + + + /// + public async Task EnsureJobsAsync() + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + scope.WriteLock(Constants.Locks.DistributedJobs); + + DistributedBackgroundJobModel[] existingJobs = _distributedJobRepository.GetAll().ToArray(); + var existingJobsByName = existingJobs.ToDictionary(x => x.Name); + + foreach (IDistributedBackgroundJob registeredJob in _distributedBackgroundJobs) + { + if (existingJobsByName.TryGetValue(registeredJob.Name, out DistributedBackgroundJobModel? existingJob)) + { + // Update if period has changed + if (existingJob.Period != registeredJob.Period) + { + existingJob.Period = registeredJob.Period; + _distributedJobRepository.Update(existingJob); + } + } + else + { + // Add new job (fresh install or newly registered job) + var newJob = new DistributedBackgroundJobModel + { + Name = registeredJob.Name, + Period = registeredJob.Period, + LastRun = DateTime.UtcNow, + IsRunning = false, + LastAttemptedRun = DateTime.UtcNow, + }; + _distributedJobRepository.Add(newJob); + } + } + + // Remove jobs that are no longer registered in code + var registeredJobNames = _distributedBackgroundJobs.Select(x => x.Name).ToHashSet(); + IEnumerable jobsToRemove = existingJobs.Where(x => registeredJobNames.Contains(x.Name) is false); + + foreach (DistributedBackgroundJobModel jobToRemove in jobsToRemove) + { + _distributedJobRepository.Delete(jobToRemove); + } + + scope.Complete(); + } +} diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index 1d8b0f97d182..18629d7cfd30 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -1,13 +1,10 @@ using System.Data.Common; using System.Net.Http.Headers; using System.Reflection; -using System.Runtime.CompilerServices; -using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.DataProtection.Infrastructure; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ApplicationModels; -using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -36,6 +33,7 @@ using Umbraco.Cms.Core.Web; using Umbraco.Cms.Infrastructure.BackgroundJobs; using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; +using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs; using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration; using Umbraco.Cms.Infrastructure.DependencyInjection; using Umbraco.Cms.Infrastructure.HostedServices; @@ -172,35 +170,6 @@ public static IUmbracoBuilder AddUmbracoCore(this IUmbracoBuilder builder) return builder; } - /// - /// Add Umbraco recurring background jobs - /// - public static IUmbracoBuilder AddRecurringBackgroundJobs(this IUmbracoBuilder builder) - { - // Add background jobs - builder.Services.AddRecurringBackgroundJob(); - builder.Services.AddRecurringBackgroundJob(); - builder.Services.AddRecurringBackgroundJob(); - builder.Services.AddRecurringBackgroundJob(); - builder.Services.AddRecurringBackgroundJob(); - builder.Services.AddRecurringBackgroundJob(); - builder.Services.AddRecurringBackgroundJob(); - builder.Services.AddRecurringBackgroundJob(); - builder.Services.AddRecurringBackgroundJob(); - builder.Services.AddRecurringBackgroundJob(); - builder.Services.AddRecurringBackgroundJob(); - builder.Services.AddRecurringBackgroundJob(); - builder.Services.AddRecurringBackgroundJob(); - - builder.Services.AddSingleton(RecurringBackgroundJobHostedService.CreateHostedServiceFactory); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.AddNotificationAsyncHandler(); - builder.AddNotificationAsyncHandler(); - - return builder; - } - /// /// Adds the Umbraco request profiler /// diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Scheduling/ContentVersionCleanupTest.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Scheduling/ContentVersionCleanupTest.cs index 35bd9e3450d5..513748dc3184 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Scheduling/ContentVersionCleanupTest.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Scheduling/ContentVersionCleanupTest.cs @@ -9,6 +9,7 @@ using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Sync; using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; +using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs; using Umbraco.Cms.Infrastructure.HostedServices; using Umbraco.Cms.Tests.UnitTests.AutoFixture; @@ -35,7 +36,7 @@ public async Task ContentVersionCleanup_WhenNotEnabled_DoesNotCleanupWillRepeat( mainDom.Setup(x => x.IsMainDom).Returns(true); serverRoleAccessor.Setup(x => x.CurrentServerRole).Returns(ServerRole.SchedulingPublisher); - await sut.RunJobAsync(); + await sut.ExecuteAsync(); cleanupService.Verify(x => x.PerformContentVersionCleanup(It.IsAny()), Times.Never); } @@ -59,7 +60,7 @@ public async Task ContentVersionCleanup_Enabled_DelegatesToCleanupService( mainDom.Setup(x => x.IsMainDom).Returns(true); serverRoleAccessor.Setup(x => x.CurrentServerRole).Returns(ServerRole.SchedulingPublisher); - await sut.RunJobAsync(); + await sut.ExecuteAsync(); cleanupService.Verify(x => x.PerformContentVersionCleanup(It.IsAny()), Times.Once); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/CacheInstructionsPruningJobTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/CacheInstructionsPruningJobTests.cs index 5ac59498fb48..45432489c1bb 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/CacheInstructionsPruningJobTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/CacheInstructionsPruningJobTests.cs @@ -10,6 +10,7 @@ using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; +using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs.Jobs; @@ -43,7 +44,7 @@ public async Task RunJobAsync_Calls_DeleteInstructionsOlderThan_With_Expected_Da var job = CreateCacheInstructionsPruningJob(timeToRetainInstructions: timeToRetainInstructions); - await job.RunJobAsync(); + await job.ExecuteAsync(); _cacheInstructionRepositoryMock.Verify(repo => repo.DeleteInstructionsOlderThan(expectedPruneDate), Times.Once); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/HealthCheckNotifierJobTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/HealthCheckNotifierJobTests.cs index 701f5c1d4380..ab4aa772eba9 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/HealthCheckNotifierJobTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/HealthCheckNotifierJobTests.cs @@ -1,24 +1,16 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Moq; using NUnit.Framework; -using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.HealthChecks; using Umbraco.Cms.Core.HealthChecks.NotificationMethods; using Umbraco.Cms.Core.Logging; -using Umbraco.Cms.Core.Runtime; -using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Core.Sync; -using Umbraco.Cms.Infrastructure.BackgroundJobs; -using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; +using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs; using Umbraco.Cms.Infrastructure.Scoping; using Umbraco.Cms.Tests.Common; @@ -37,7 +29,7 @@ public class HealthCheckNotifierJobTests public async Task Does_Not_Execute_When_Not_Enabled() { var sut = CreateHealthCheckNotifier(false); - await sut.RunJobAsync(); + await sut.ExecuteAsync(); VerifyNotificationsNotSent(); } @@ -45,7 +37,7 @@ public async Task Does_Not_Execute_When_Not_Enabled() public async Task Does_Not_Execute_With_No_Enabled_Notification_Methods() { var sut = CreateHealthCheckNotifier(notificationEnabled: false); - await sut.RunJobAsync(); + await sut.ExecuteAsync(); VerifyNotificationsNotSent(); } @@ -53,7 +45,7 @@ public async Task Does_Not_Execute_With_No_Enabled_Notification_Methods() public async Task Executes_With_Enabled_Notification_Methods() { var sut = CreateHealthCheckNotifier(); - await sut.RunJobAsync(); + await sut.ExecuteAsync(); VerifyNotificationsSent(); } @@ -61,7 +53,7 @@ public async Task Executes_With_Enabled_Notification_Methods() public async Task Executes_Only_Enabled_Checks() { var sut = CreateHealthCheckNotifier(); - await sut.RunJobAsync(); + await sut.ExecuteAsync(); _mockNotificationMethod.Verify( x => x.SendAsync( It.Is(y => @@ -96,7 +88,6 @@ private HealthCheckNotifierJob CreateHealthCheckNotifier( var mockScopeProvider = new Mock(); - var mockLogger = new Mock>(); var mockProfilingLogger = new Mock(); return new HealthCheckNotifierJob( @@ -104,9 +95,7 @@ private HealthCheckNotifierJob CreateHealthCheckNotifier( checks, notifications, mockScopeProvider.Object, - mockLogger.Object, mockProfilingLogger.Object, - Mock.Of(), Mock.Of()); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/LogScrubberJobTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/LogScrubberJobTests.cs index 515111cd64fa..9f294cf44d21 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/LogScrubberJobTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/LogScrubberJobTests.cs @@ -2,19 +2,15 @@ // See LICENSE for more details. using System.Data; -using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Moq; using NUnit.Framework; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Logging; -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.Jobs; -using Umbraco.Cms.Infrastructure.HostedServices; +using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs; using Umbraco.Cms.Tests.Common; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs.Jobs; @@ -30,7 +26,7 @@ public class LogScrubberJobTests public async Task Executes_And_Scrubs_Logs() { var sut = CreateLogScrubber(); - await sut.RunJobAsync(); + await sut.ExecuteAsync(); VerifyLogsScrubbed(); } @@ -50,7 +46,6 @@ private LogScrubberJob CreateLogScrubber() It.IsAny(), It.IsAny())) .Returns(mockScope.Object); - var mockLogger = new Mock>(); var mockProfilingLogger = new Mock(); _mockAuditService = new Mock(); @@ -59,7 +54,6 @@ private LogScrubberJob CreateLogScrubber() _mockAuditService.Object, new TestOptionsMonitor(settings), mockScopeProvider.Object, - mockLogger.Object, mockProfilingLogger.Object); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ScheduledPublishingJobTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ScheduledPublishingJobTests.cs index 9e549e02fde0..3c70f7f4816e 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ScheduledPublishingJobTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ScheduledPublishingJobTests.cs @@ -15,6 +15,7 @@ using Umbraco.Cms.Core.Web; using Umbraco.Cms.Infrastructure; using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; +using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs; using Umbraco.Cms.Infrastructure.HostedServices; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs.Jobs; @@ -29,7 +30,7 @@ public class ScheduledPublishingJobTests public async Task Does_Not_Execute_When_Not_Enabled() { var sut = CreateScheduledPublishing(enabled: false); - await sut.RunJobAsync(); + await sut.ExecuteAsync(); VerifyScheduledPublishingNotPerformed(); } @@ -37,7 +38,7 @@ public async Task Does_Not_Execute_When_Not_Enabled() public async Task Executes_And_Performs_Scheduled_Publishing() { var sut = CreateScheduledPublishing(); - await sut.RunJobAsync(); + await sut.ExecuteAsync(); VerifyScheduledPublishingPerformed(); } diff --git a/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs b/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs index 1a874e40dcf9..6cdcab73b09f 100644 --- a/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs +++ b/tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs @@ -75,6 +75,8 @@ public class UmbracoCmsDefinition public required WebhookSettings Webhook { get; set; } public required CacheSettings Cache { get; set; } + + public required DistributedJobSettings DistributedJobSettings { get; set; } } public class InstallDefaultDataNamedOptions