diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/DelayCalculator.cs b/src/Umbraco.Infrastructure/BackgroundJobs/DelayCalculator.cs
new file mode 100644
index 0000000000..42d016c066
--- /dev/null
+++ b/src/Umbraco.Infrastructure/BackgroundJobs/DelayCalculator.cs
@@ -0,0 +1,67 @@
+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;
+
+namespace Umbraco.Cms.Infrastructure.BackgroundJobs
+{
+ public class DelayCalculator
+ {
+ ///
+ /// 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.
+ ///
+ /// The configured time to first run the task in crontab format.
+ /// An instance of
+ /// The logger.
+ /// The default delay to use when a first run time is not configured.
+ /// The delay before first running the recurring task.
+ public static TimeSpan GetDelay(
+ string firstRunTime,
+ ICronTabParser cronTabParser,
+ ILogger logger,
+ TimeSpan defaultDelay) => GetDelay(firstRunTime, cronTabParser, logger, DateTime.Now, defaultDelay);
+
+ ///
+ /// 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.
+ ///
+ /// The configured time to first run the task in crontab format.
+ /// An instance of
+ /// The logger.
+ /// The current datetime.
+ /// The default delay to use when a first run time is not configured.
+ /// The delay before first running the recurring task.
+ /// Internal to expose for unit tests.
+ 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;
+ }
+ }
+}
diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/IRecurringBackgroundJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/IRecurringBackgroundJob.cs
new file mode 100644
index 0000000000..c6be3dcec5
--- /dev/null
+++ b/src/Umbraco.Infrastructure/BackgroundJobs/IRecurringBackgroundJob.cs
@@ -0,0 +1,28 @@
+using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.Sync;
+
+namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
+///
+/// A recurring background job
+///
+public interface IRecurringBackgroundJob
+{
+ static readonly TimeSpan DefaultDelay = System.TimeSpan.FromMinutes(3);
+ static readonly ServerRole[] DefaultServerRoles = new[] { ServerRole.Single, ServerRole.SchedulingPublisher };
+
+ /// Timespan representing how often the task should recur.
+ TimeSpan Period { get; }
+
+ ///
+ /// Timespan representing the initial delay after application start-up before the first run of the task
+ /// occurs.
+ ///
+ TimeSpan Delay { get => DefaultDelay; }
+
+ ServerRole[] ServerRoles { get => DefaultServerRoles; }
+
+ event EventHandler PeriodChanged;
+
+ Task RunJobAsync();
+}
+
diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ContentVersionCleanupJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ContentVersionCleanupJob.cs
new file mode 100644
index 0000000000..cb89d600aa
--- /dev/null
+++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ContentVersionCleanupJob.cs
@@ -0,0 +1,66 @@
+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;
+
+///
+/// Recurring hosted service that executes the content history cleanup.
+///
+public class ContentVersionCleanupJob : IRecurringBackgroundJob
+{
+
+ 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;
+ private readonly IOptionsMonitor _settingsMonitor;
+
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ContentVersionCleanupJob(
+ ILogger logger,
+ IOptionsMonitor settingsMonitor,
+ IContentVersionService service)
+ {
+ _logger = logger;
+ _settingsMonitor = settingsMonitor;
+ _service = service;
+ }
+
+ ///
+ public Task RunJobAsync()
+ {
+ // Globally disabled by feature flag
+ if (!_settingsMonitor.CurrentValue.ContentVersionCleanupPolicy.EnableCleanup)
+ {
+ _logger.LogInformation(
+ "ContentVersionCleanup task will not run as it has been globally disabled via configuration");
+ return Task.CompletedTask;
+ }
+
+
+ var count = _service.PerformContentVersionCleanup(DateTime.Now).Count;
+
+ if (count > 0)
+ {
+ _logger.LogInformation("Deleted {count} ContentVersion(s)", count);
+ }
+ else
+ {
+ _logger.LogDebug("Task complete, no items were Deleted");
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/HealthCheckNotifierJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/HealthCheckNotifierJob.cs
new file mode 100644
index 0000000000..ba78af33b4
--- /dev/null
+++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/HealthCheckNotifierJob.cs
@@ -0,0 +1,116 @@
+// 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;
+using Umbraco.Cms.Core.Configuration.Models;
+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.Scoping;
+using Umbraco.Cms.Core.Services;
+using Umbraco.Cms.Core.Sync;
+using Umbraco.Extensions;
+
+namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
+
+///
+/// Hosted service implementation for recurring health check notifications.
+///
+public class HealthCheckNotifierJob : IRecurringBackgroundJob
+{
+
+
+ public TimeSpan Period { get; private set; }
+ public TimeSpan Delay { get; private set; }
+
+ private event EventHandler? _periodChanged;
+ public event EventHandler PeriodChanged
+ {
+ add { _periodChanged += value; }
+ remove { _periodChanged -= value; }
+ }
+
+ private readonly HealthCheckCollection _healthChecks;
+ private readonly ILogger _logger;
+ private readonly HealthCheckNotificationMethodCollection _notifications;
+ private readonly IProfilingLogger _profilingLogger;
+ private readonly ICoreScopeProvider _scopeProvider;
+ private HealthChecksSettings _healthChecksSettings;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The configuration for health check settings.
+ /// The collection of healthchecks.
+ /// The collection of healthcheck notification methods.
+ /// Provides scopes for database operations.
+ /// The typed logger.
+ /// The profiling logger.
+ /// Parser of crontab expressions.
+ public HealthCheckNotifierJob(
+ IOptionsMonitor healthChecksSettings,
+ HealthCheckCollection healthChecks,
+ HealthCheckNotificationMethodCollection notifications,
+ ICoreScopeProvider scopeProvider,
+ ILogger logger,
+ IProfilingLogger profilingLogger,
+ ICronTabParser cronTabParser)
+ {
+ _healthChecksSettings = healthChecksSettings.CurrentValue;
+ _healthChecks = healthChecks;
+ _notifications = notifications;
+ _scopeProvider = scopeProvider;
+ _logger = logger;
+ _profilingLogger = profilingLogger;
+
+ 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()
+ {
+ if (_healthChecksSettings.Notification.Enabled == false)
+ {
+ return;
+ }
+
+ // Ensure we use an explicit scope since we are running on a background thread and plugin health
+ // checks can be making service/database calls so we want to ensure the CallContext/Ambient scope
+ // isn't used since that can be problematic.
+ using (ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true))
+ using (_profilingLogger.DebugDuration("Health checks executing", "Health checks complete"))
+ {
+ // Don't notify for any checks that are disabled, nor for any disabled just for notifications.
+ Guid[] disabledCheckIds = _healthChecksSettings.Notification.DisabledChecks
+ .Select(x => x.Id)
+ .Union(_healthChecksSettings.DisabledChecks
+ .Select(x => x.Id))
+ .Distinct()
+ .ToArray();
+
+ IEnumerable checks = _healthChecks
+ .Where(x => disabledCheckIds.Contains(x.Id) == false);
+
+ HealthCheckResults results = await HealthCheckResults.Create(checks);
+ results.LogResults();
+
+ // Send using registered notification methods that are enabled.
+ foreach (IHealthCheckNotificationMethod notificationMethod in _notifications.Where(x => x.Enabled))
+ {
+ await notificationMethod.SendAsync(results);
+ }
+ }
+ }
+}
diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/KeepAliveJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/KeepAliveJob.cs
new file mode 100644
index 0000000000..a9849ddbb7
--- /dev/null
+++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/KeepAliveJob.cs
@@ -0,0 +1,90 @@
+// 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.Hosting;
+using Umbraco.Cms.Core.Logging;
+using Umbraco.Cms.Core.Routing;
+using Umbraco.Cms.Core.Runtime;
+using Umbraco.Cms.Core.Sync;
+using Umbraco.Extensions;
+
+namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
+
+///
+/// Hosted service implementation for keep alive feature.
+///
+public class KeepAliveJob : IRecurringBackgroundJob
+{
+ public TimeSpan Period { get => TimeSpan.FromMinutes(5); }
+
+ // No-op event as the period never changes on this job
+ public event EventHandler PeriodChanged { add { } remove { } }
+
+ private readonly IHostingEnvironment _hostingEnvironment;
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly ILogger _logger;
+ private readonly IProfilingLogger _profilingLogger;
+ private KeepAliveSettings _keepAliveSettings;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The current hosting environment
+ /// The configuration for keep alive settings.
+ /// The typed logger.
+ /// The profiling logger.
+ /// Factory for instances.
+ public KeepAliveJob(
+ IHostingEnvironment hostingEnvironment,
+ IOptionsMonitor keepAliveSettings,
+ ILogger logger,
+ IProfilingLogger profilingLogger,
+ IHttpClientFactory httpClientFactory)
+ {
+ _hostingEnvironment = hostingEnvironment;
+ _keepAliveSettings = keepAliveSettings.CurrentValue;
+ _logger = logger;
+ _profilingLogger = profilingLogger;
+ _httpClientFactory = httpClientFactory;
+
+ keepAliveSettings.OnChange(x => _keepAliveSettings = x);
+ }
+
+ public async Task RunJobAsync()
+ {
+ if (_keepAliveSettings.DisableKeepAliveTask)
+ {
+ return;
+ }
+
+ using (_profilingLogger.DebugDuration("Keep alive executing", "Keep alive complete"))
+ {
+ var umbracoAppUrl = _hostingEnvironment.ApplicationMainUrl?.ToString();
+ if (umbracoAppUrl.IsNullOrWhiteSpace())
+ {
+ _logger.LogWarning("No umbracoApplicationUrl for service (yet), skip.");
+ return;
+ }
+
+ // If the config is an absolute path, just use it
+ var keepAlivePingUrl = WebPath.Combine(
+ umbracoAppUrl!,
+ _hostingEnvironment.ToAbsolute(_keepAliveSettings.KeepAlivePingUrl));
+
+ try
+ {
+ var request = new HttpRequestMessage(HttpMethod.Get, keepAlivePingUrl);
+ HttpClient httpClient = _httpClientFactory.CreateClient(Constants.HttpClients.IgnoreCertificateErrors);
+ _ = await httpClient.SendAsync(request);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Keep alive failed (at '{keepAlivePingUrl}').", keepAlivePingUrl);
+ }
+ }
+ }
+}
diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/LogScrubberJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/LogScrubberJob.cs
new file mode 100644
index 0000000000..1c745661cb
--- /dev/null
+++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/LogScrubberJob.cs
@@ -0,0 +1,73 @@
+// 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;
+
+///
+/// Log scrubbing hosted service.
+///
+///
+/// Will only run on non-replica servers.
+///
+public class LogScrubberJob : IRecurringBackgroundJob
+{
+ public TimeSpan Period { get => TimeSpan.FromHours(4); }
+
+ // No-op event as the period never changes on this job
+ public event EventHandler PeriodChanged { add { } remove { } }
+
+
+ private readonly IAuditService _auditService;
+ private readonly ILogger _logger;
+ private readonly IProfilingLogger _profilingLogger;
+ private readonly ICoreScopeProvider _scopeProvider;
+ private LoggingSettings _settings;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Service for handling audit operations.
+ /// The configuration for logging settings.
+ /// Provides scopes for database operations.
+ /// The typed logger.
+ /// The profiling logger.
+ 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 Task RunJobAsync()
+ {
+
+ // Ensure we use an explicit scope since we are running on a background thread.
+ using (ICoreScope scope = _scopeProvider.CreateCoreScope())
+ using (_profilingLogger.DebugDuration("Log scrubbing executing", "Log scrubbing complete"))
+ {
+ _auditService.CleanLogs((int)_settings.MaxLogAge.TotalMinutes);
+ _ = scope.Complete();
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ReportSiteJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ReportSiteJob.cs
new file mode 100644
index 0000000000..5d39b57add
--- /dev/null
+++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ReportSiteJob.cs
@@ -0,0 +1,92 @@
+using System.Text;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Newtonsoft.Json;
+using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.Configuration;
+using Umbraco.Cms.Core.Configuration.Models;
+using Umbraco.Cms.Core.DependencyInjection;
+using Umbraco.Cms.Core.Services;
+using Umbraco.Cms.Core.Sync;
+using Umbraco.Cms.Core.Telemetry;
+using Umbraco.Cms.Core.Telemetry.Models;
+
+namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
+
+public class ReportSiteJob : IRecurringBackgroundJob
+{
+
+ public TimeSpan Period { get => TimeSpan.FromDays(1); }
+ public TimeSpan Delay { get => TimeSpan.FromMinutes(5); }
+ public ServerRole[] ServerRoles { get => Enum.GetValues(); }
+
+ // No-op event as the period never changes on this job
+ public event EventHandler PeriodChanged { add { } remove { } }
+
+
+ private static HttpClient _httpClient = new();
+ private readonly ILogger _logger;
+ private readonly ITelemetryService _telemetryService;
+
+
+ public ReportSiteJob(
+ ILogger logger,
+ ITelemetryService telemetryService)
+ {
+ _logger = logger;
+ _telemetryService = telemetryService;
+ _httpClient = new HttpClient();
+ }
+
+ ///
+ /// Runs the background task to send the anonymous ID
+ /// to telemetry service
+ ///
+ public async Task RunJobAsync()
+ {
+
+ if (_telemetryService.TryGetTelemetryReportData(out TelemetryReportData? telemetryReportData) is false)
+ {
+ _logger.LogWarning("No telemetry marker found");
+
+ return;
+ }
+
+ try
+ {
+ if (_httpClient.BaseAddress is null)
+ {
+ // Send data to LIVE telemetry
+ _httpClient.BaseAddress = new Uri("https://telemetry.umbraco.com/");
+
+#if DEBUG
+ // Send data to DEBUG telemetry service
+ _httpClient.BaseAddress = new Uri("https://telemetry.rainbowsrock.net/");
+#endif
+ }
+
+ _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Content-Type", "application/json");
+
+ using (var request = new HttpRequestMessage(HttpMethod.Post, "installs/"))
+ {
+ request.Content = new StringContent(JsonConvert.SerializeObject(telemetryReportData), Encoding.UTF8,
+ "application/json");
+
+ // Make a HTTP Post to telemetry service
+ // https://telemetry.umbraco.com/installs/
+ // Fire & Forget, do not need to know if its a 200, 500 etc
+ using (await _httpClient.SendAsync(request))
+ {
+ }
+ }
+ }
+ catch
+ {
+ // Silently swallow
+ // The user does not need the logs being polluted if our service has fallen over or is down etc
+ // Hence only logging this at a more verbose level (which users should not be using in production)
+ _logger.LogDebug("There was a problem sending a request to the Umbraco telemetry service");
+ }
+ }
+}
diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ScheduledPublishingJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ScheduledPublishingJob.cs
new file mode 100644
index 0000000000..f815366a21
--- /dev/null
+++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ScheduledPublishingJob.cs
@@ -0,0 +1,105 @@
+// Copyright (c) Umbraco.
+// See LICENSE for more details.
+
+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;
+
+///
+/// Hosted service implementation for scheduled publishing feature.
+///
+///
+/// Runs only on non-replica servers.
+///
+public class ScheduledPublishingJob : IRecurringBackgroundJob
+{
+ public TimeSpan Period { get => TimeSpan.FromMinutes(1); }
+ // No-op event as the period never changes on this job
+ public event EventHandler PeriodChanged { add { } remove { } }
+
+
+ private readonly IContentService _contentService;
+ private readonly ILogger _logger;
+ private readonly ICoreScopeProvider _scopeProvider;
+ private readonly IServerMessenger _serverMessenger;
+ private readonly IUmbracoContextFactory _umbracoContextFactory;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ScheduledPublishingJob(
+ IContentService contentService,
+ IUmbracoContextFactory umbracoContextFactory,
+ ILogger logger,
+ IServerMessenger serverMessenger,
+ ICoreScopeProvider scopeProvider)
+ {
+ _contentService = contentService;
+ _umbracoContextFactory = umbracoContextFactory;
+ _logger = logger;
+ _serverMessenger = serverMessenger;
+ _scopeProvider = scopeProvider;
+ }
+
+ public Task RunJobAsync()
+ {
+ if (Suspendable.ScheduledPublishing.CanRun == false)
+ {
+ return Task.CompletedTask;
+ }
+
+ try
+ {
+ // Ensure we run with an UmbracoContext, because this will run in a background task,
+ // and developers may be using the UmbracoContext in the event handlers.
+
+ // TODO: or maybe not, CacheRefresherComponent already ensures a context when handling events
+ // - UmbracoContext 'current' needs to be refactored and cleaned up
+ // - batched messenger should not depend on a current HttpContext
+ // but then what should be its "scope"? could we attach it to scopes?
+ // - and we should definitively *not* have to flush it here (should be auto)
+ using UmbracoContextReference contextReference = _umbracoContextFactory.EnsureUmbracoContext();
+ using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true);
+
+ /* We used to assume that there will never be two instances running concurrently where (IsMainDom && ServerRole == SchedulingPublisher)
+ * However this is possible during an azure deployment slot swap for the SchedulingPublisher instance when trying to achieve zero downtime deployments.
+ * If we take a distributed write lock, we are certain that the multiple instances of the job will not run in parallel.
+ * It's possible that during the swapping process we may run this job more frequently than intended but this is not of great concern and it's
+ * only until the old SchedulingPublisher shuts down. */
+ scope.EagerWriteLock(Constants.Locks.ScheduledPublishing);
+ try
+ {
+ // Run
+ IEnumerable result = _contentService.PerformScheduledPublish(DateTime.Now);
+ foreach (IGrouping grouped in result.GroupBy(x => x.Result))
+ {
+ _logger.LogInformation(
+ "Scheduled publishing result: '{StatusCount}' items with status {Status}",
+ grouped.Count(),
+ grouped.Key);
+ }
+ }
+ finally
+ {
+ // If running on a temp context, we have to flush the messenger
+ if (contextReference.IsRoot)
+ {
+ _serverMessenger.SendMessages();
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ // important to catch *everything* to ensure the task repeats
+ _logger.LogError(ex, "Failed.");
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/InstructionProcessJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/InstructionProcessJob.cs
new file mode 100644
index 0000000000..cc6e35bf83
--- /dev/null
+++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/InstructionProcessJob.cs
@@ -0,0 +1,59 @@
+// 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.Services;
+using Umbraco.Cms.Core.Sync;
+
+namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
+
+///
+/// Implements periodic database instruction processing as a hosted service.
+///
+public class InstructionProcessJob : IRecurringBackgroundJob
+{
+
+ public TimeSpan Period { get; }
+ public TimeSpan Delay { get => TimeSpan.FromMinutes(1); }
+ public ServerRole[] ServerRoles { get => Enum.GetValues(); }
+
+ // No-op event as the period never changes on this job
+ public event EventHandler PeriodChanged { add { } remove { } }
+
+ private readonly ILogger _logger;
+ private readonly IServerMessenger _messenger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Service broadcasting cache notifications to registered servers.
+ /// The typed logger.
+ /// The configuration for global settings.
+ public InstructionProcessJob(
+ IServerMessenger messenger,
+ ILogger logger,
+ IOptions globalSettings)
+ {
+ _messenger = messenger;
+ _logger = logger;
+
+ Period = globalSettings.Value.DatabaseServerMessenger.TimeBetweenSyncOperations;
+ }
+
+ public Task RunJobAsync()
+ {
+ try
+ {
+ _messenger.Sync();
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "Failed (will repeat).");
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/TouchServerJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/TouchServerJob.cs
new file mode 100644
index 0000000000..8258da1a35
--- /dev/null
+++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/TouchServerJob.cs
@@ -0,0 +1,102 @@
+// 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.Hosting;
+using Umbraco.Cms.Core.Services;
+using Umbraco.Cms.Core.Sync;
+using Umbraco.Extensions;
+
+namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
+
+///
+/// Implements periodic server "touching" (to mark as active/deactive) as a hosted service.
+///
+public class TouchServerJob : IRecurringBackgroundJob
+{
+ public TimeSpan Period { get; private set; }
+ public TimeSpan Delay { get => TimeSpan.FromSeconds(15); }
+
+ // Runs on all servers
+ public ServerRole[] ServerRoles { get => Enum.GetValues(); }
+
+ private event EventHandler? _periodChanged;
+ public event EventHandler PeriodChanged {
+ add { _periodChanged += value; }
+ remove { _periodChanged -= value; }
+ }
+
+
+ private readonly IHostingEnvironment _hostingEnvironment;
+ private readonly ILogger _logger;
+ private readonly IServerRegistrationService _serverRegistrationService;
+ private readonly IServerRoleAccessor _serverRoleAccessor;
+ private GlobalSettings _globalSettings;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Services for server registrations.
+ /// The typed logger.
+ /// The configuration for global settings.
+ /// The hostingEnviroment.
+ /// The accessor for the server role
+ public TouchServerJob(
+ IServerRegistrationService serverRegistrationService,
+ IHostingEnvironment hostingEnvironment,
+ ILogger logger,
+ IOptionsMonitor globalSettings,
+ IServerRoleAccessor serverRoleAccessor)
+ {
+ _serverRegistrationService = serverRegistrationService ??
+ throw new ArgumentNullException(nameof(serverRegistrationService));
+ _hostingEnvironment = hostingEnvironment;
+ _logger = logger;
+ _globalSettings = globalSettings.CurrentValue;
+ _serverRoleAccessor = serverRoleAccessor;
+
+ Period = _globalSettings.DatabaseServerRegistrar.WaitTimeBetweenCalls;
+ globalSettings.OnChange(x =>
+ {
+ _globalSettings = x;
+ Period = x.DatabaseServerRegistrar.WaitTimeBetweenCalls;
+
+ _periodChanged?.Invoke(this, EventArgs.Empty);
+ });
+ }
+
+ public Task RunJobAsync()
+ {
+
+ // If the IServerRoleAccessor has been changed away from ElectedServerRoleAccessor this task no longer makes sense,
+ // since all it's used for is to allow the ElectedServerRoleAccessor
+ // to figure out what role a given server has, so we just stop this task.
+ if (_serverRoleAccessor is not ElectedServerRoleAccessor)
+ {
+ return Task.CompletedTask;
+ }
+
+ var serverAddress = _hostingEnvironment.ApplicationMainUrl?.ToString();
+ if (serverAddress.IsNullOrWhiteSpace())
+ {
+ _logger.LogWarning("No umbracoApplicationUrl for service (yet), skip.");
+ return Task.CompletedTask;
+ }
+
+ try
+ {
+ _serverRegistrationService.TouchServer(
+ serverAddress!,
+ _globalSettings.DatabaseServerRegistrar.StaleServerTimeout);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to update server record in database.");
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/TempFileCleanupJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/TempFileCleanupJob.cs
new file mode 100644
index 0000000000..ac88a8edce
--- /dev/null
+++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/TempFileCleanupJob.cs
@@ -0,0 +1,99 @@
+// Copyright (c) Umbraco.
+// See LICENSE for more details.
+
+using Microsoft.Extensions.Logging;
+using Umbraco.Cms.Core.IO;
+using Umbraco.Cms.Core.Runtime;
+using Umbraco.Cms.Core.Sync;
+
+namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
+
+///
+/// Used to cleanup temporary file locations.
+///
+///
+/// Will run on all servers - even though file upload should only be handled on the scheduling publisher, this will
+/// ensure that in the case it happens on subscribers that they are cleaned up too.
+///
+public class TempFileCleanupJob : IRecurringBackgroundJob
+{
+ public TimeSpan Period { get => TimeSpan.FromMinutes(60); }
+
+ // Runs on all servers
+ public ServerRole[] ServerRoles { get => Enum.GetValues(); }
+
+ // No-op event as the period never changes on this job
+ public event EventHandler PeriodChanged { add { } remove { } }
+
+ private readonly TimeSpan _age = TimeSpan.FromDays(1);
+ private readonly IIOHelper _ioHelper;
+ private readonly ILogger _logger;
+ private readonly DirectoryInfo[] _tempFolders;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Helper service for IO operations.
+ /// The typed logger.
+ public TempFileCleanupJob(IIOHelper ioHelper, ILogger logger)
+ {
+ _ioHelper = ioHelper;
+ _logger = logger;
+
+ _tempFolders = _ioHelper.GetTempFolders();
+ }
+
+ public Task RunJobAsync()
+ {
+ foreach (DirectoryInfo folder in _tempFolders)
+ {
+ CleanupFolder(folder);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private void CleanupFolder(DirectoryInfo folder)
+ {
+ CleanFolderResult result = _ioHelper.CleanFolder(folder, _age);
+ switch (result.Status)
+ {
+ case CleanFolderResultStatus.FailedAsDoesNotExist:
+ _logger.LogDebug("The cleanup folder doesn't exist {Folder}", folder.FullName);
+ break;
+ case CleanFolderResultStatus.FailedWithException:
+ foreach (CleanFolderResult.Error error in result.Errors!)
+ {
+ _logger.LogError(error.Exception, "Could not delete temp file {FileName}",
+ error.ErroringFile.FullName);
+ }
+
+ break;
+ }
+
+ folder.Refresh(); // In case it's changed during runtime
+ if (!folder.Exists)
+ {
+ _logger.LogDebug("The cleanup folder doesn't exist {Folder}", folder.FullName);
+ return;
+ }
+
+ FileInfo[] files = folder.GetFiles("*.*", SearchOption.AllDirectories);
+ foreach (FileInfo file in files)
+ {
+ if (DateTime.UtcNow - file.LastWriteTimeUtc > _age)
+ {
+ try
+ {
+ file.IsReadOnly = false;
+ file.Delete();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Could not delete temp file {FileName}", file.FullName);
+ }
+ }
+ }
+ }
+
+}
diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedService.cs b/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedService.cs
new file mode 100644
index 0000000000..80afdb903c
--- /dev/null
+++ b/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedService.cs
@@ -0,0 +1,127 @@
+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.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;
+
+namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
+
+public static class RecurringBackgroundJobHostedService
+{
+ public static Func CreateHostedServiceFactory(IServiceProvider serviceProvider) =>
+ (IRecurringBackgroundJob job) =>
+ {
+ Type hostedServiceType = typeof(RecurringBackgroundJobHostedService<>).MakeGenericType(job.GetType());
+ return (IHostedService)ActivatorUtilities.CreateInstance(serviceProvider, hostedServiceType, job);
+ };
+}
+
+///
+/// Runs a recurring background job inside a hosted service.
+/// Generic version for DependencyInjection
+///
+/// Type of the Job
+public class RecurringBackgroundJobHostedService : RecurringHostedServiceBase where TJob : IRecurringBackgroundJob
+{
+
+ private readonly ILogger> _logger;
+ private readonly IMainDom _mainDom;
+ private readonly IRuntimeState _runtimeState;
+ private readonly IServerRoleAccessor _serverRoleAccessor;
+ private readonly IEventAggregator _eventAggregator;
+ private readonly IRecurringBackgroundJob _job;
+
+ public RecurringBackgroundJobHostedService(
+ IRuntimeState runtimeState,
+ ILogger> logger,
+ IMainDom mainDom,
+ 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);
+ }
+
+ ///
+ public override async Task PerformExecuteAsync(object? state)
+ {
+ var executingNotification = new Notifications.RecurringBackgroundJobExecutingNotification(_job, new EventMessages());
+ await _eventAggregator.PublishAsync(executingNotification);
+
+ 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));
+ return;
+ }
+
+ // Don't run on replicas nor unknown role servers
+ if (!_job.ServerRoles.Contains(_serverRoleAccessor.CurrentServerRole))
+ {
+ _logger.LogDebug("Job not running on this server role");
+ await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobIgnoredNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
+ return;
+ }
+
+ // Ensure we do not run if not main domain, but do NOT lock it
+ if (!_mainDom.IsMainDom)
+ {
+ _logger.LogDebug("Job not running as not MainDom");
+ await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobIgnoredNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
+ return;
+ }
+
+
+ await _job.RunJobAsync();
+ await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobExecutedNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
+
+
+ }
+ catch (Exception ex)
+ {
+ await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobFailedNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
+ _logger.LogError(ex, "Unhandled exception in recurring background job.");
+ }
+
+ }
+
+ public override async Task StartAsync(CancellationToken cancellationToken)
+ {
+ var startingNotification = new Notifications.RecurringBackgroundJobStartingNotification(_job, new EventMessages());
+ await _eventAggregator.PublishAsync(startingNotification);
+
+ await base.StartAsync(cancellationToken);
+
+ await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobStartedNotification(_job, new EventMessages()).WithStateFrom(startingNotification));
+
+ }
+
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ var stoppingNotification = new Notifications.RecurringBackgroundJobStoppingNotification(_job, new EventMessages());
+ await _eventAggregator.PublishAsync(stoppingNotification);
+
+ await base.StopAsync(cancellationToken);
+
+ await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobStoppedNotification(_job, new EventMessages()).WithStateFrom(stoppingNotification));
+ }
+}
diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceRunner.cs b/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceRunner.cs
new file mode 100644
index 0000000000..0e56bfa2b1
--- /dev/null
+++ b/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceRunner.cs
@@ -0,0 +1,81 @@
+using System.Linq;
+using System.Threading;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Umbraco.Cms.Core.Runtime;
+using Umbraco.Cms.Core.Services;
+using Umbraco.Cms.Core.Sync;
+using Umbraco.Cms.Infrastructure.ModelsBuilder;
+
+namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
+
+///
+/// A hosted service that discovers and starts hosted services for any recurring background jobs in the DI container.
+///
+public class RecurringBackgroundJobHostedServiceRunner : IHostedService
+{
+ private readonly ILogger _logger;
+ private readonly List _jobs;
+ private readonly Func _jobFactory;
+ private IList _hostedServices = new List();
+
+
+ public RecurringBackgroundJobHostedServiceRunner(
+ ILogger logger,
+ IEnumerable jobs,
+ Func jobFactory)
+ {
+ _jobs = jobs.ToList();
+ _logger = logger;
+ _jobFactory = jobFactory;
+ }
+
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Creating recurring background jobs hosted services");
+
+ // create hosted services for each background job
+ _hostedServices = _jobs.Select(_jobFactory).ToList();
+
+ _logger.LogInformation("Starting recurring background jobs hosted services");
+
+ foreach (IHostedService hostedService in _hostedServices)
+ {
+ try
+ {
+ _logger.LogInformation($"Starting background hosted service for {hostedService.GetType().Name}");
+ await hostedService.StartAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception exception)
+ {
+ _logger.LogError(exception, $"Failed to start background hosted service for {hostedService.GetType().Name}");
+ }
+ }
+
+ _logger.LogInformation("Completed starting recurring background jobs hosted services");
+
+
+ }
+
+ public async Task StopAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation("Stopping recurring background jobs hosted services");
+
+ foreach (IHostedService hostedService in _hostedServices)
+ {
+ try
+ {
+ _logger.LogInformation($"Stopping background hosted service for {hostedService.GetType().Name}");
+ await hostedService.StopAsync(stoppingToken).ConfigureAwait(false);
+ }
+ catch (Exception exception)
+ {
+ _logger.LogError(exception, $"Failed to stop background hosted service for {hostedService.GetType().Name}");
+ }
+ }
+
+ _logger.LogInformation("Completed stopping recurring background jobs hosted services");
+
+ }
+}
diff --git a/src/Umbraco.Infrastructure/Extensions/ServiceCollectionExtensions.cs b/src/Umbraco.Infrastructure/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000000..efaac24ceb
--- /dev/null
+++ b/src/Umbraco.Infrastructure/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,31 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Umbraco.Cms.Core.Composing;
+using Umbraco.Cms.Infrastructure.BackgroundJobs;
+
+namespace Umbraco.Extensions;
+
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Adds a recurring background job with an implementation type of
+ /// to the specified .
+ ///
+ public static void AddRecurringBackgroundJob(
+ this IServiceCollection services)
+ where TJob : class, IRecurringBackgroundJob =>
+ services.AddSingleton();
+
+ ///
+ /// Adds a recurring background job with an implementation type of
+ /// using the factory
+ /// to the specified .
+ ///
+ public static void AddRecurringBackgroundJob(
+ this IServiceCollection services,
+ Func implementationFactory)
+ where TJob : class, IRecurringBackgroundJob =>
+ services.AddSingleton(implementationFactory);
+
+}
+
diff --git a/src/Umbraco.Infrastructure/HostedServices/ContentVersionCleanup.cs b/src/Umbraco.Infrastructure/HostedServices/ContentVersionCleanup.cs
index 37eeb668f9..1ecfcbf926 100644
--- a/src/Umbraco.Infrastructure/HostedServices/ContentVersionCleanup.cs
+++ b/src/Umbraco.Infrastructure/HostedServices/ContentVersionCleanup.cs
@@ -11,6 +11,7 @@ namespace Umbraco.Cms.Infrastructure.HostedServices;
///
/// Recurring hosted service that executes the content history cleanup.
///
+[Obsolete("Use Umbraco.Cms.Infrastructure.BackgroundJobs.ContentVersionCleanupJob instead. This class will be removed in Umbraco 14.")]
public class ContentVersionCleanup : RecurringHostedServiceBase
{
private readonly ILogger _logger;
diff --git a/src/Umbraco.Infrastructure/HostedServices/HealthCheckNotifier.cs b/src/Umbraco.Infrastructure/HostedServices/HealthCheckNotifier.cs
index e1a10d9f71..5ee76d1a18 100644
--- a/src/Umbraco.Infrastructure/HostedServices/HealthCheckNotifier.cs
+++ b/src/Umbraco.Infrastructure/HostedServices/HealthCheckNotifier.cs
@@ -20,6 +20,7 @@ namespace Umbraco.Cms.Infrastructure.HostedServices;
///
/// Hosted service implementation for recurring health check notifications.
///
+[Obsolete("Use Umbraco.Cms.Infrastructure.BackgroundJobs.HealthCheckNotifierJob instead. This class will be removed in Umbraco 14.")]
public class HealthCheckNotifier : RecurringHostedServiceBase
{
private readonly HealthCheckCollection _healthChecks;
diff --git a/src/Umbraco.Infrastructure/HostedServices/KeepAlive.cs b/src/Umbraco.Infrastructure/HostedServices/KeepAlive.cs
index 5db59ff225..978ffa2dd1 100644
--- a/src/Umbraco.Infrastructure/HostedServices/KeepAlive.cs
+++ b/src/Umbraco.Infrastructure/HostedServices/KeepAlive.cs
@@ -17,6 +17,7 @@ namespace Umbraco.Cms.Infrastructure.HostedServices;
///
/// Hosted service implementation for keep alive feature.
///
+[Obsolete("Use Umbraco.Cms.Infrastructure.BackgroundJobs.KeepAliveJob instead. This class will be removed in Umbraco 14.")]
public class KeepAlive : RecurringHostedServiceBase
{
private readonly IHostingEnvironment _hostingEnvironment;
diff --git a/src/Umbraco.Infrastructure/HostedServices/LogScrubber.cs b/src/Umbraco.Infrastructure/HostedServices/LogScrubber.cs
index 9ae0dfe656..4c3df658c6 100644
--- a/src/Umbraco.Infrastructure/HostedServices/LogScrubber.cs
+++ b/src/Umbraco.Infrastructure/HostedServices/LogScrubber.cs
@@ -18,6 +18,7 @@ namespace Umbraco.Cms.Infrastructure.HostedServices;
///
/// Will only run on non-replica servers.
///
+[Obsolete("Use Umbraco.Cms.Infrastructure.BackgroundJobs.LogScrubberJob instead. This class will be removed in Umbraco 14.")]
public class LogScrubber : RecurringHostedServiceBase
{
private readonly IAuditService _auditService;
diff --git a/src/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBase.cs b/src/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBase.cs
index c100da0ab2..a35f7aa956 100644
--- a/src/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBase.cs
+++ b/src/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBase.cs
@@ -107,7 +107,7 @@ public abstract class RecurringHostedServiceBase : IHostedService, IDisposable
}
///
- public Task StartAsync(CancellationToken cancellationToken)
+ public virtual Task StartAsync(CancellationToken cancellationToken)
{
using (!ExecutionContext.IsFlowSuppressed() ? (IDisposable)ExecutionContext.SuppressFlow() : null)
{
@@ -118,7 +118,7 @@ public abstract class RecurringHostedServiceBase : IHostedService, IDisposable
}
///
- public Task StopAsync(CancellationToken cancellationToken)
+ public virtual Task StopAsync(CancellationToken cancellationToken)
{
_period = Timeout.InfiniteTimeSpan;
_timer?.Change(Timeout.Infinite, 0);
diff --git a/src/Umbraco.Infrastructure/HostedServices/ReportSiteTask.cs b/src/Umbraco.Infrastructure/HostedServices/ReportSiteTask.cs
index 7184aaf16e..77800ae7d6 100644
--- a/src/Umbraco.Infrastructure/HostedServices/ReportSiteTask.cs
+++ b/src/Umbraco.Infrastructure/HostedServices/ReportSiteTask.cs
@@ -13,6 +13,7 @@ using Umbraco.Cms.Core.Telemetry.Models;
namespace Umbraco.Cms.Infrastructure.HostedServices;
+[Obsolete("Use Umbraco.Cms.Infrastructure.BackgroundJobs.ReportSiteJob instead. This class will be removed in Umbraco 14.")]
public class ReportSiteTask : RecurringHostedServiceBase
{
private static HttpClient _httpClient = new();
diff --git a/src/Umbraco.Infrastructure/HostedServices/ScheduledPublishing.cs b/src/Umbraco.Infrastructure/HostedServices/ScheduledPublishing.cs
index da1fbaf157..efbd8017df 100644
--- a/src/Umbraco.Infrastructure/HostedServices/ScheduledPublishing.cs
+++ b/src/Umbraco.Infrastructure/HostedServices/ScheduledPublishing.cs
@@ -17,6 +17,7 @@ namespace Umbraco.Cms.Infrastructure.HostedServices;
///
/// Runs only on non-replica servers.
///
+[Obsolete("Use Umbraco.Cms.Infrastructure.BackgroundJobs.ScheduledPublishingJob instead. This class will be removed in Umbraco 14.")]
public class ScheduledPublishing : RecurringHostedServiceBase
{
private readonly IContentService _contentService;
diff --git a/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTask.cs b/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTask.cs
index e4e5700496..fbbdab8878 100644
--- a/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTask.cs
+++ b/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTask.cs
@@ -13,6 +13,7 @@ namespace Umbraco.Cms.Infrastructure.HostedServices.ServerRegistration;
///
/// Implements periodic database instruction processing as a hosted service.
///
+[Obsolete("Use Umbraco.Cms.Infrastructure.BackgroundJobs.ServerRegistration.InstructionProcessJob instead. This class will be removed in Umbraco 14.")]
public class InstructionProcessTask : RecurringHostedServiceBase
{
private readonly ILogger _logger;
diff --git a/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTask.cs b/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTask.cs
index 730282c6b0..a844c33ad6 100644
--- a/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTask.cs
+++ b/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTask.cs
@@ -15,6 +15,7 @@ namespace Umbraco.Cms.Infrastructure.HostedServices.ServerRegistration;
///
/// Implements periodic server "touching" (to mark as active/deactive) as a hosted service.
///
+[Obsolete("Use Umbraco.Cms.Infrastructure.BackgroundJobs.ServerRegistration.TouchServerJob instead. This class will be removed in Umbraco 14.")]
public class TouchServerTask : RecurringHostedServiceBase
{
private readonly IHostingEnvironment _hostingEnvironment;
diff --git a/src/Umbraco.Infrastructure/HostedServices/TempFileCleanup.cs b/src/Umbraco.Infrastructure/HostedServices/TempFileCleanup.cs
index cf46e38750..81de651e79 100644
--- a/src/Umbraco.Infrastructure/HostedServices/TempFileCleanup.cs
+++ b/src/Umbraco.Infrastructure/HostedServices/TempFileCleanup.cs
@@ -14,6 +14,7 @@ namespace Umbraco.Cms.Infrastructure.HostedServices;
/// Will run on all servers - even though file upload should only be handled on the scheduling publisher, this will
/// ensure that in the case it happens on subscribers that they are cleaned up too.
///
+[Obsolete("Use Umbraco.Cms.Infrastructure.BackgroundJobs.TempFileCleanupJob instead. This class will be removed in Umbraco 14.")]
public class TempFileCleanup : RecurringHostedServiceBase
{
private readonly TimeSpan _age = TimeSpan.FromDays(1);
diff --git a/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobExecutedNotification.cs b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobExecutedNotification.cs
new file mode 100644
index 0000000000..8d2fbf96aa
--- /dev/null
+++ b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobExecutedNotification.cs
@@ -0,0 +1,12 @@
+using Umbraco.Cms.Core.Events;
+using Umbraco.Cms.Infrastructure.BackgroundJobs;
+
+namespace Umbraco.Cms.Infrastructure.Notifications
+{
+ public sealed class RecurringBackgroundJobExecutedNotification : RecurringBackgroundJobNotification
+ {
+ public RecurringBackgroundJobExecutedNotification(IRecurringBackgroundJob target, EventMessages messages) : base(target, messages)
+ {
+ }
+ }
+}
diff --git a/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobExecutingNotification.cs b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobExecutingNotification.cs
new file mode 100644
index 0000000000..71f5cf3edc
--- /dev/null
+++ b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobExecutingNotification.cs
@@ -0,0 +1,12 @@
+using Umbraco.Cms.Core.Events;
+using Umbraco.Cms.Infrastructure.BackgroundJobs;
+
+namespace Umbraco.Cms.Infrastructure.Notifications
+{
+ public sealed class RecurringBackgroundJobExecutingNotification : RecurringBackgroundJobNotification
+ {
+ public RecurringBackgroundJobExecutingNotification(IRecurringBackgroundJob target, EventMessages messages) : base(target, messages)
+ {
+ }
+ }
+}
diff --git a/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobFailedNotification.cs b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobFailedNotification.cs
new file mode 100644
index 0000000000..594f01fc7b
--- /dev/null
+++ b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobFailedNotification.cs
@@ -0,0 +1,12 @@
+using Umbraco.Cms.Core.Events;
+using Umbraco.Cms.Infrastructure.BackgroundJobs;
+
+namespace Umbraco.Cms.Infrastructure.Notifications
+{
+ public sealed class RecurringBackgroundJobFailedNotification : RecurringBackgroundJobNotification
+ {
+ public RecurringBackgroundJobFailedNotification(IRecurringBackgroundJob target, EventMessages messages) : base(target, messages)
+ {
+ }
+ }
+}
diff --git a/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobIgnoredNotification.cs b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobIgnoredNotification.cs
new file mode 100644
index 0000000000..8c3a0079d7
--- /dev/null
+++ b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobIgnoredNotification.cs
@@ -0,0 +1,12 @@
+using Umbraco.Cms.Core.Events;
+using Umbraco.Cms.Infrastructure.BackgroundJobs;
+
+namespace Umbraco.Cms.Infrastructure.Notifications
+{
+ public sealed class RecurringBackgroundJobIgnoredNotification : RecurringBackgroundJobNotification
+ {
+ public RecurringBackgroundJobIgnoredNotification(IRecurringBackgroundJob target, EventMessages messages) : base(target, messages)
+ {
+ }
+ }
+}
diff --git a/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobNotification.cs b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobNotification.cs
new file mode 100644
index 0000000000..f9185cb412
--- /dev/null
+++ b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobNotification.cs
@@ -0,0 +1,12 @@
+using Umbraco.Cms.Core.Events;
+using Umbraco.Cms.Core.Notifications;
+using Umbraco.Cms.Infrastructure.BackgroundJobs;
+
+namespace Umbraco.Cms.Infrastructure.Notifications
+{
+ public class RecurringBackgroundJobNotification : ObjectNotification
+ {
+ public IRecurringBackgroundJob Job { get; }
+ public RecurringBackgroundJobNotification(IRecurringBackgroundJob target, EventMessages messages) : base(target, messages) => Job = target;
+ }
+}
diff --git a/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStartedNotification.cs b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStartedNotification.cs
new file mode 100644
index 0000000000..dca1e69d40
--- /dev/null
+++ b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStartedNotification.cs
@@ -0,0 +1,12 @@
+using Umbraco.Cms.Core.Events;
+using Umbraco.Cms.Infrastructure.BackgroundJobs;
+
+namespace Umbraco.Cms.Infrastructure.Notifications
+{
+ public sealed class RecurringBackgroundJobStartedNotification : RecurringBackgroundJobNotification
+ {
+ public RecurringBackgroundJobStartedNotification(IRecurringBackgroundJob target, EventMessages messages) : base(target, messages)
+ {
+ }
+ }
+}
diff --git a/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStartingNotification.cs b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStartingNotification.cs
new file mode 100644
index 0000000000..3ee8d2a710
--- /dev/null
+++ b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStartingNotification.cs
@@ -0,0 +1,12 @@
+using Umbraco.Cms.Core.Events;
+using Umbraco.Cms.Infrastructure.BackgroundJobs;
+
+namespace Umbraco.Cms.Infrastructure.Notifications
+{
+ public sealed class RecurringBackgroundJobStartingNotification : RecurringBackgroundJobNotification
+ {
+ public RecurringBackgroundJobStartingNotification(IRecurringBackgroundJob target, EventMessages messages) : base(target, messages)
+ {
+ }
+ }
+}
diff --git a/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStoppedNotification.cs b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStoppedNotification.cs
new file mode 100644
index 0000000000..a1df71a4ee
--- /dev/null
+++ b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStoppedNotification.cs
@@ -0,0 +1,12 @@
+using Umbraco.Cms.Core.Events;
+using Umbraco.Cms.Infrastructure.BackgroundJobs;
+
+namespace Umbraco.Cms.Infrastructure.Notifications
+{
+ public sealed class RecurringBackgroundJobStoppedNotification : RecurringBackgroundJobNotification
+ {
+ public RecurringBackgroundJobStoppedNotification(IRecurringBackgroundJob target, EventMessages messages) : base(target, messages)
+ {
+ }
+ }
+}
diff --git a/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStoppingNotification.cs b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStoppingNotification.cs
new file mode 100644
index 0000000000..985a20e286
--- /dev/null
+++ b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStoppingNotification.cs
@@ -0,0 +1,12 @@
+using Umbraco.Cms.Core.Events;
+using Umbraco.Cms.Infrastructure.BackgroundJobs;
+
+namespace Umbraco.Cms.Infrastructure.Notifications
+{
+ public sealed class RecurringBackgroundJobStoppingNotification : RecurringBackgroundJobNotification
+ {
+ public RecurringBackgroundJobStoppingNotification(IRecurringBackgroundJob target, EventMessages messages) : base(target, messages)
+ {
+ }
+ }
+}
diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs
index dfa22b06e8..7c23ce19da 100644
--- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs
+++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs
@@ -46,7 +46,7 @@ public static partial class UmbracoBuilderExtensions
.AddMvcAndRazor(configureMvc)
.AddWebServer()
.AddPreviewSupport()
- .AddHostedServices()
+ .AddRecurringBackgroundJobs()
.AddNuCache()
.AddDistributedCache()
.TryAddModelsBuilderDashboard()
diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs
index 2dd828f9c2..71704f4edc 100644
--- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs
+++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs
@@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Serilog.Extensions.Logging;
@@ -38,6 +39,9 @@ using Umbraco.Cms.Core.Telemetry;
using Umbraco.Cms.Core.Templates;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Core.WebAssets;
+using Umbraco.Cms.Infrastructure.BackgroundJobs;
+using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
+using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
using Umbraco.Cms.Infrastructure.DependencyInjection;
using Umbraco.Cms.Infrastructure.HostedServices;
using Umbraco.Cms.Infrastructure.HostedServices.ServerRegistration;
@@ -176,6 +180,7 @@ public static partial class UmbracoBuilderExtensions
///
/// Add Umbraco hosted services
///
+ [Obsolete("Use AddRecurringBackgroundJobs instead")]
public static IUmbracoBuilder AddHostedServices(this IUmbracoBuilder builder)
{
builder.Services.AddHostedService();
@@ -191,6 +196,36 @@ public static partial class UmbracoBuilderExtensions
new ReportSiteTask(
provider.GetRequiredService>(),
provider.GetRequiredService()));
+
+
+ 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(provider =>
+ new ReportSiteJob(
+ provider.GetRequiredService>(),
+ provider.GetRequiredService()));
+
+
+ builder.Services.AddHostedService();
+ builder.Services.AddSingleton(RecurringBackgroundJobHostedService.CreateHostedServiceFactory);
+ builder.Services.AddHostedService();
+
+
return builder;
}
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/HealthCheckNotifierJobTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/HealthCheckNotifierJobTests.cs
new file mode 100644
index 0000000000..cf9883603b
--- /dev/null
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/HealthCheckNotifierJobTests.cs
@@ -0,0 +1,139 @@
+// 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.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.Scoping;
+using Umbraco.Cms.Tests.Common;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs.Jobs;
+
+[TestFixture]
+public class HealthCheckNotifierJobTests
+{
+ private Mock _mockNotificationMethod;
+
+ private const string Check1Id = "00000000-0000-0000-0000-000000000001";
+ private const string Check2Id = "00000000-0000-0000-0000-000000000002";
+ private const string Check3Id = "00000000-0000-0000-0000-000000000003";
+
+ [Test]
+ public async Task Does_Not_Execute_When_Not_Enabled()
+ {
+ var sut = CreateHealthCheckNotifier(false);
+ await sut.RunJobAsync();
+ VerifyNotificationsNotSent();
+ }
+
+ [Test]
+ public async Task Does_Not_Execute_With_No_Enabled_Notification_Methods()
+ {
+ var sut = CreateHealthCheckNotifier(notificationEnabled: false);
+ await sut.RunJobAsync();
+ VerifyNotificationsNotSent();
+ }
+
+ [Test]
+ public async Task Executes_With_Enabled_Notification_Methods()
+ {
+ var sut = CreateHealthCheckNotifier();
+ await sut.RunJobAsync();
+ VerifyNotificationsSent();
+ }
+
+ [Test]
+ public async Task Executes_Only_Enabled_Checks()
+ {
+ var sut = CreateHealthCheckNotifier();
+ await sut.RunJobAsync();
+ _mockNotificationMethod.Verify(
+ x => x.SendAsync(
+ It.Is(y =>
+ y.ResultsAsDictionary.Count == 1 && y.ResultsAsDictionary.ContainsKey("Check1"))),
+ Times.Once);
+ }
+
+ private HealthCheckNotifierJob CreateHealthCheckNotifier(
+ bool enabled = true,
+ bool notificationEnabled = true)
+ {
+ var settings = new HealthChecksSettings
+ {
+ Notification = new HealthChecksNotificationSettings
+ {
+ Enabled = enabled,
+ DisabledChecks = new List { new() { Id = Guid.Parse(Check3Id) } },
+ },
+ DisabledChecks = new List { new() { Id = Guid.Parse(Check2Id) } },
+ };
+ var checks = new HealthCheckCollection(() => new List
+ {
+ new TestHealthCheck1(),
+ new TestHealthCheck2(),
+ new TestHealthCheck3(),
+ });
+
+ _mockNotificationMethod = new Mock();
+ _mockNotificationMethod.SetupGet(x => x.Enabled).Returns(notificationEnabled);
+ var notifications = new HealthCheckNotificationMethodCollection(() =>
+ new List { _mockNotificationMethod.Object });
+
+
+ var mockScopeProvider = new Mock();
+ var mockLogger = new Mock>();
+ var mockProfilingLogger = new Mock();
+
+ return new HealthCheckNotifierJob(
+ new TestOptionsMonitor(settings),
+ checks,
+ notifications,
+ mockScopeProvider.Object,
+ mockLogger.Object,
+ mockProfilingLogger.Object,
+ Mock.Of());
+ }
+
+ private void VerifyNotificationsNotSent() => VerifyNotificationsSentTimes(Times.Never());
+
+ private void VerifyNotificationsSent() => VerifyNotificationsSentTimes(Times.Once());
+
+ private void VerifyNotificationsSentTimes(Times times) =>
+ _mockNotificationMethod.Verify(x => x.SendAsync(It.IsAny()), times);
+
+ [HealthCheck(Check1Id, "Check1")]
+ private class TestHealthCheck1 : TestHealthCheck
+ {
+ }
+
+ [HealthCheck(Check2Id, "Check2")]
+ private class TestHealthCheck2 : TestHealthCheck
+ {
+ }
+
+ [HealthCheck(Check3Id, "Check3")]
+ private class TestHealthCheck3 : TestHealthCheck
+ {
+ }
+
+ private class TestHealthCheck : HealthCheck
+ {
+ public override HealthCheckStatus ExecuteAction(HealthCheckAction action) => new("Check message");
+
+ public override async Task> GetStatus() => Enumerable.Empty();
+ }
+}
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/KeepAliveJobTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/KeepAliveJobTests.cs
new file mode 100644
index 0000000000..6821cbcccc
--- /dev/null
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/KeepAliveJobTests.cs
@@ -0,0 +1,94 @@
+// Copyright (c) Umbraco.
+// See LICENSE for more details.
+
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Moq.Protected;
+using NUnit.Framework;
+using Umbraco.Cms.Core.Configuration.Models;
+using Umbraco.Cms.Core.Hosting;
+using Umbraco.Cms.Core.Logging;
+using Umbraco.Cms.Core.Runtime;
+using Umbraco.Cms.Core.Scoping;
+using Umbraco.Cms.Core.Sync;
+using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
+using Umbraco.Cms.Infrastructure.HostedServices;
+using Umbraco.Cms.Tests.Common;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs.Jobs;
+
+[TestFixture]
+public class KeepAliveJobTests
+{
+ private Mock _mockHttpMessageHandler;
+
+ private const string ApplicationUrl = "https://mysite.com";
+
+ [Test]
+ public async Task Does_Not_Execute_When_Not_Enabled()
+ {
+ var sut = CreateKeepAlive(false);
+ await sut.RunJobAsync();
+ VerifyKeepAliveRequestNotSent();
+ }
+
+
+ [Test]
+ public async Task Executes_And_Calls_Ping_Url()
+ {
+ var sut = CreateKeepAlive();
+ await sut.RunJobAsync();
+ VerifyKeepAliveRequestSent();
+ }
+
+ private KeepAliveJob CreateKeepAlive(
+ bool enabled = true)
+ {
+ var settings = new KeepAliveSettings { DisableKeepAliveTask = !enabled };
+
+ var mockHostingEnvironment = new Mock();
+ mockHostingEnvironment.SetupGet(x => x.ApplicationMainUrl).Returns(new Uri(ApplicationUrl));
+ mockHostingEnvironment.Setup(x => x.ToAbsolute(It.IsAny()))
+ .Returns((string s) => s.TrimStart('~'));
+
+ var mockScopeProvider = new Mock();
+ var mockLogger = new Mock>();
+ var mockProfilingLogger = new Mock();
+
+ _mockHttpMessageHandler = new Mock();
+ _mockHttpMessageHandler.Protected()
+ .Setup>(
+ "SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK))
+ .Verifiable();
+ _mockHttpMessageHandler.As().Setup(s => s.Dispose());
+ var httpClient = new HttpClient(_mockHttpMessageHandler.Object);
+
+ var mockHttpClientFactory = new Mock(MockBehavior.Strict);
+ mockHttpClientFactory.Setup(x => x.CreateClient(It.IsAny())).Returns(httpClient);
+
+ return new KeepAliveJob(
+ mockHostingEnvironment.Object,
+ new TestOptionsMonitor(settings),
+ mockLogger.Object,
+ mockProfilingLogger.Object,
+ mockHttpClientFactory.Object);
+ }
+
+ private void VerifyKeepAliveRequestNotSent() => VerifyKeepAliveRequestSentTimes(Times.Never());
+
+ private void VerifyKeepAliveRequestSent() => VerifyKeepAliveRequestSentTimes(Times.Once());
+
+ private void VerifyKeepAliveRequestSentTimes(Times times) => _mockHttpMessageHandler.Protected()
+ .Verify(
+ "SendAsync",
+ times,
+ ItExpr.Is(x => x.RequestUri.ToString() == $"{ApplicationUrl}/api/keepalive/ping"),
+ ItExpr.IsAny());
+}
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/LogScrubberJobTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/LogScrubberJobTests.cs
new file mode 100644
index 0000000000..6dd479364c
--- /dev/null
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/LogScrubberJobTests.cs
@@ -0,0 +1,72 @@
+// Copyright (c) Umbraco.
+// 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.Tests.Common;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs.Jobs;
+
+[TestFixture]
+public class LogScrubberJobTests
+{
+ private Mock _mockAuditService;
+
+ private const int MaxLogAgeInMinutes = 60;
+
+ [Test]
+ public async Task Executes_And_Scrubs_Logs()
+ {
+ var sut = CreateLogScrubber();
+ await sut.RunJobAsync();
+ VerifyLogsScrubbed();
+ }
+
+ private LogScrubberJob CreateLogScrubber()
+ {
+ var settings = new LoggingSettings { MaxLogAge = TimeSpan.FromMinutes(MaxLogAgeInMinutes) };
+
+ var mockScope = new Mock();
+ var mockScopeProvider = new Mock();
+ mockScopeProvider
+ .Setup(x => x.CreateCoreScope(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(mockScope.Object);
+ var mockLogger = new Mock>();
+ var mockProfilingLogger = new Mock();
+
+ _mockAuditService = new Mock();
+
+ return new LogScrubberJob(
+ _mockAuditService.Object,
+ new TestOptionsMonitor(settings),
+ mockScopeProvider.Object,
+ mockLogger.Object,
+ mockProfilingLogger.Object);
+ }
+
+ private void VerifyLogsNotScrubbed() => VerifyLogsScrubbed(Times.Never());
+
+ private void VerifyLogsScrubbed() => VerifyLogsScrubbed(Times.Once());
+
+ private void VerifyLogsScrubbed(Times times) =>
+ _mockAuditService.Verify(x => x.CleanLogs(It.Is(y => y == MaxLogAgeInMinutes)), times);
+}
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ScheduledPublishingJobTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ScheduledPublishingJobTests.cs
new file mode 100644
index 0000000000..eb1f0695c8
--- /dev/null
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ScheduledPublishingJobTests.cs
@@ -0,0 +1,92 @@
+// Copyright (c) Umbraco.
+// 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;
+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.Core.Web;
+using Umbraco.Cms.Infrastructure;
+using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
+using Umbraco.Cms.Infrastructure.HostedServices;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs.Jobs;
+
+[TestFixture]
+public class ScheduledPublishingJobTests
+{
+ private Mock _mockContentService;
+ private Mock> _mockLogger;
+
+ [Test]
+ public async Task Does_Not_Execute_When_Not_Enabled()
+ {
+ var sut = CreateScheduledPublishing(enabled: false);
+ await sut.RunJobAsync();
+ VerifyScheduledPublishingNotPerformed();
+ }
+
+ [Test]
+ public async Task Executes_And_Performs_Scheduled_Publishing()
+ {
+ var sut = CreateScheduledPublishing();
+ await sut.RunJobAsync();
+ VerifyScheduledPublishingPerformed();
+ }
+
+ private ScheduledPublishingJob CreateScheduledPublishing(
+ bool enabled = true)
+ {
+ if (enabled)
+ {
+ Suspendable.ScheduledPublishing.Resume();
+ }
+ else
+ {
+ Suspendable.ScheduledPublishing.Suspend();
+ }
+
+ _mockContentService = new Mock();
+
+ var mockUmbracoContextFactory = new Mock();
+ mockUmbracoContextFactory.Setup(x => x.EnsureUmbracoContext())
+ .Returns(new UmbracoContextReference(null, false, null));
+
+ _mockLogger = new Mock>();
+
+ var mockServerMessenger = new Mock();
+
+ var mockScopeProvider = new Mock();
+ mockScopeProvider
+ .Setup(x => x.CreateCoreScope(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(Mock.Of());
+
+ return new ScheduledPublishingJob(
+ _mockContentService.Object,
+ mockUmbracoContextFactory.Object,
+ _mockLogger.Object,
+ mockServerMessenger.Object,
+ mockScopeProvider.Object);
+ }
+
+ private void VerifyScheduledPublishingNotPerformed() => VerifyScheduledPublishingPerformed(Times.Never());
+
+ private void VerifyScheduledPublishingPerformed() => VerifyScheduledPublishingPerformed(Times.Once());
+
+ private void VerifyScheduledPublishingPerformed(Times times) =>
+ _mockContentService.Verify(x => x.PerformScheduledPublish(It.IsAny()), times);
+}
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/InstructionProcessJobTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/InstructionProcessJobTests.cs
new file mode 100644
index 0000000000..f2e6e574ef
--- /dev/null
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/InstructionProcessJobTests.cs
@@ -0,0 +1,51 @@
+// Copyright (c) Umbraco.
+// See LICENSE for more details.
+
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using NUnit.Framework;
+using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.Configuration.Models;
+using Umbraco.Cms.Core.Services;
+using Umbraco.Cms.Core.Sync;
+using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
+
+[TestFixture]
+public class InstructionProcessJobTests
+{
+ private Mock _mockDatabaseServerMessenger;
+
+
+ [Test]
+ public async Task Executes_And_Touches_Server()
+ {
+ var sut = CreateInstructionProcessJob();
+ await sut.RunJobAsync();
+ VerifyMessengerSynced();
+ }
+
+ private InstructionProcessJob CreateInstructionProcessJob()
+ {
+
+ var mockLogger = new Mock>();
+
+ _mockDatabaseServerMessenger = new Mock();
+
+ var settings = new GlobalSettings();
+
+ return new InstructionProcessJob(
+ _mockDatabaseServerMessenger.Object,
+ mockLogger.Object,
+ Options.Create(settings));
+ }
+
+ private void VerifyMessengerNotSynced() => VerifyMessengerSyncedTimes(Times.Never());
+
+ private void VerifyMessengerSynced() => VerifyMessengerSyncedTimes(Times.Once());
+
+ private void VerifyMessengerSyncedTimes(Times times) => _mockDatabaseServerMessenger.Verify(x => x.Sync(), times);
+}
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/TouchServerJobTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/TouchServerJobTests.cs
new file mode 100644
index 0000000000..0da3b15e1b
--- /dev/null
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/TouchServerJobTests.cs
@@ -0,0 +1,95 @@
+// Copyright (c) Umbraco.
+// See LICENSE for more details.
+
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Moq;
+using NUnit.Framework;
+using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.Configuration.Models;
+using Umbraco.Cms.Core.Hosting;
+using Umbraco.Cms.Core.Services;
+using Umbraco.Cms.Core.Sync;
+using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
+using Umbraco.Cms.Infrastructure.HostedServices.ServerRegistration;
+using Umbraco.Cms.Tests.Common;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
+
+[TestFixture]
+public class TouchServerJobTests
+{
+ private Mock _mockServerRegistrationService;
+
+ private const string ApplicationUrl = "https://mysite.com/";
+ private readonly TimeSpan _staleServerTimeout = TimeSpan.FromMinutes(2);
+
+
+ [Test]
+ public async Task Does_Not_Execute_When_Application_Url_Is_Not_Available()
+ {
+ var sut = CreateTouchServerTask(applicationUrl: string.Empty);
+ await sut.RunJobAsync();
+ VerifyServerNotTouched();
+ }
+
+ [Test]
+ public async Task Executes_And_Touches_Server()
+ {
+ var sut = CreateTouchServerTask();
+ await sut.RunJobAsync();
+ VerifyServerTouched();
+ }
+
+ [Test]
+ public async Task Does_Not_Execute_When_Role_Accessor_Is_Not_Elected()
+ {
+ var sut = CreateTouchServerTask(useElection: false);
+ await sut.RunJobAsync();
+ VerifyServerNotTouched();
+ }
+
+ private TouchServerJob CreateTouchServerTask(
+ RuntimeLevel runtimeLevel = RuntimeLevel.Run,
+ string applicationUrl = ApplicationUrl,
+ bool useElection = true)
+ {
+ var mockRequestAccessor = new Mock();
+ mockRequestAccessor.SetupGet(x => x.ApplicationMainUrl)
+ .Returns(!string.IsNullOrEmpty(applicationUrl) ? new Uri(ApplicationUrl) : null);
+
+ var mockRunTimeState = new Mock();
+ mockRunTimeState.SetupGet(x => x.Level).Returns(runtimeLevel);
+
+ var mockLogger = new Mock>();
+
+ _mockServerRegistrationService = new Mock();
+
+ var settings = new GlobalSettings
+ {
+ DatabaseServerRegistrar = new DatabaseServerRegistrarSettings { StaleServerTimeout = _staleServerTimeout },
+ };
+
+ IServerRoleAccessor roleAccessor = useElection
+ ? new ElectedServerRoleAccessor(_mockServerRegistrationService.Object)
+ : new SingleServerRoleAccessor();
+
+ return new TouchServerJob(
+ _mockServerRegistrationService.Object,
+ mockRequestAccessor.Object,
+ mockLogger.Object,
+ new TestOptionsMonitor(settings),
+ roleAccessor);
+ }
+
+ private void VerifyServerNotTouched() => VerifyServerTouchedTimes(Times.Never());
+
+ private void VerifyServerTouched() => VerifyServerTouchedTimes(Times.Once());
+
+ private void VerifyServerTouchedTimes(Times times) => _mockServerRegistrationService
+ .Verify(
+ x => x.TouchServer(
+ It.Is(y => y == ApplicationUrl),
+ It.Is(y => y == _staleServerTimeout)),
+ times);
+}
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/TempFileCleanupJobTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/TempFileCleanupJobTests.cs
new file mode 100644
index 0000000000..c37094e6ac
--- /dev/null
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/TempFileCleanupJobTests.cs
@@ -0,0 +1,50 @@
+// Copyright (c) Umbraco.
+// See LICENSE for more details.
+
+using System.IO;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Moq;
+using NUnit.Framework;
+using Umbraco.Cms.Core.IO;
+using Umbraco.Cms.Core.Runtime;
+using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs.Jobs
+{
+ [TestFixture]
+ public class TempFileCleanupJobTests
+ {
+ private Mock _mockIOHelper;
+ private readonly string _testPath = Path.Combine(TestContext.CurrentContext.TestDirectory.Split("bin")[0], "App_Data", "TEMP");
+
+
+ [Test]
+ public async Task Executes_And_Cleans_Files()
+ {
+ TempFileCleanupJob sut = CreateTempFileCleanupJob();
+ await sut.RunJobAsync();
+ VerifyFilesCleaned();
+ }
+
+ private TempFileCleanupJob CreateTempFileCleanupJob()
+ {
+
+ _mockIOHelper = new Mock();
+ _mockIOHelper.Setup(x => x.GetTempFolders())
+ .Returns(new DirectoryInfo[] { new(_testPath) });
+ _mockIOHelper.Setup(x => x.CleanFolder(It.IsAny(), It.IsAny()))
+ .Returns(CleanFolderResult.Success());
+
+ var mockLogger = new Mock>();
+
+ return new TempFileCleanupJob(_mockIOHelper.Object,mockLogger.Object);
+ }
+
+ private void VerifyFilesNotCleaned() => VerifyFilesCleaned(Times.Never());
+
+ private void VerifyFilesCleaned() => VerifyFilesCleaned(Times.Once());
+
+ private void VerifyFilesCleaned(Times times) => _mockIOHelper.Verify(x => x.CleanFolder(It.Is(y => y.FullName == _testPath), It.IsAny()), times);
+ }
+}
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceTests.cs
new file mode 100644
index 0000000000..806520bf41
--- /dev/null
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceTests.cs
@@ -0,0 +1,208 @@
+// Copyright (c) Umbraco.
+// See LICENSE for more details.
+
+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;
+
+[TestFixture]
+public class RecurringBackgroundJobHostedServiceTests
+{
+
+ [TestCase(RuntimeLevel.Boot)]
+ [TestCase(RuntimeLevel.Install)]
+ [TestCase(RuntimeLevel.Unknown)]
+ [TestCase(RuntimeLevel.Upgrade)]
+ [TestCase(RuntimeLevel.BootFailed)]
+ public async Task Does_Not_Execute_When_Runtime_State_Is_Not_Run(RuntimeLevel runtimeLevel)
+ {
+ var mockJob = new Mock();
+
+ var sut = CreateRecurringBackgroundJobHostedService(mockJob, runtimeLevel: runtimeLevel);
+ await sut.PerformExecuteAsync(null);
+
+ mockJob.Verify(job => job.RunJobAsync(), Times.Never);
+ }
+
+ [Test]
+ public async Task Publishes_Ignored_Notification_When_Runtime_State_Is_Not_Run()
+ {
+ var mockJob = new Mock();
+ var mockEventAggregator = new Mock();
+
+ var sut = CreateRecurringBackgroundJobHostedService(mockJob, runtimeLevel: RuntimeLevel.Unknown, mockEventAggregator: mockEventAggregator);
+ await sut.PerformExecuteAsync(null);
+
+ mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once);
+ mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [TestCase(ServerRole.Unknown)]
+ [TestCase(ServerRole.Subscriber)]
+ public async Task Does_Not_Execute_When_Server_Role_Is_NotDefault(ServerRole serverRole)
+ {
+ var mockJob = new Mock();
+
+ var sut = CreateRecurringBackgroundJobHostedService(mockJob, serverRole: serverRole);
+ await sut.PerformExecuteAsync(null);
+
+ mockJob.Verify(job => job.RunJobAsync(), Times.Never);
+ }
+
+ [TestCase(ServerRole.Single)]
+ [TestCase(ServerRole.SchedulingPublisher)]
+ public async Task Does_Executes_When_Server_Role_Is_Default(ServerRole serverRole)
+ {
+ var mockJob = new Mock();
+ mockJob.Setup(x => x.ServerRoles).Returns(IRecurringBackgroundJob.DefaultServerRoles);
+
+ var sut = CreateRecurringBackgroundJobHostedService(mockJob, serverRole: serverRole);
+ await sut.PerformExecuteAsync(null);
+
+ mockJob.Verify(job => job.RunJobAsync(), Times.Once);
+ }
+
+ [Test]
+ public async Task Does_Execute_When_Server_Role_Is_Subscriber_And_Specified()
+ {
+ var mockJob = new Mock();
+ mockJob.Setup(x => x.ServerRoles).Returns(new ServerRole[] { ServerRole.Subscriber });
+
+ var sut = CreateRecurringBackgroundJobHostedService(mockJob, serverRole: ServerRole.Subscriber);
+ await sut.PerformExecuteAsync(null);
+
+ mockJob.Verify(job => job.RunJobAsync(), Times.Once);
+ }
+
+ [Test]
+ public async Task Publishes_Ignored_Notification_When_Server_Role_Is_Not_Allowed()
+ {
+ var mockJob = new Mock();
+ var mockEventAggregator = new Mock();
+
+ var sut = CreateRecurringBackgroundJobHostedService(mockJob, serverRole: ServerRole.Unknown, mockEventAggregator: mockEventAggregator);
+ await sut.PerformExecuteAsync(null);
+
+ mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once);
+ mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Test]
+ public async Task Does_Not_Execute_When_Not_Main_Dom()
+ {
+ var mockJob = new Mock();
+
+ var sut = CreateRecurringBackgroundJobHostedService(mockJob, isMainDom: false);
+ await sut.PerformExecuteAsync(null);
+
+ mockJob.Verify(job => job.RunJobAsync(), Times.Never);
+ }
+
+ [Test]
+ public async Task Publishes_Ignored_Notification_When_Not_Main_Dom()
+ {
+ var mockJob = new Mock();
+ var mockEventAggregator = new Mock();
+
+ var sut = CreateRecurringBackgroundJobHostedService(mockJob, isMainDom: false, mockEventAggregator: mockEventAggregator);
+ await sut.PerformExecuteAsync(null);
+
+ mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once);
+ mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+
+
+ [Test]
+ public async Task Publishes_Executed_Notification_When_Run()
+ {
+ var mockJob = new Mock();
+ mockJob.Setup(x => x.ServerRoles).Returns(IRecurringBackgroundJob.DefaultServerRoles);
+ var mockEventAggregator = new Mock();
+
+ var sut = CreateRecurringBackgroundJobHostedService(mockJob, mockEventAggregator: mockEventAggregator);
+ await sut.PerformExecuteAsync(null);
+
+ mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once);
+ mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Test]
+ public async Task Publishes_Failed_Notification_When_Fails()
+ {
+ var mockJob = new Mock();
+ mockJob.Setup(x => x.ServerRoles).Returns(IRecurringBackgroundJob.DefaultServerRoles);
+ mockJob.Setup(x => x.RunJobAsync()).Throws();
+ var mockEventAggregator = new Mock();
+
+ var sut = CreateRecurringBackgroundJobHostedService(mockJob, mockEventAggregator: mockEventAggregator);
+ await sut.PerformExecuteAsync(null);
+
+ mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once);
+ mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Test]
+ public async Task Publishes_Start_And_Stop_Notifications()
+ {
+ var mockJob = new Mock();
+ var mockEventAggregator = new Mock();
+
+ var sut = CreateRecurringBackgroundJobHostedService(mockJob, isMainDom: false, mockEventAggregator: mockEventAggregator);
+ await sut.StartAsync(CancellationToken.None);
+ await sut.StopAsync(CancellationToken.None);
+
+
+ mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once);
+ mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once);
+
+
+ mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once);
+ mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once);
+
+ }
+
+
+ private RecurringHostedServiceBase CreateRecurringBackgroundJobHostedService(
+ Mock mockJob,
+ RuntimeLevel runtimeLevel = RuntimeLevel.Run,
+ ServerRole serverRole = ServerRole.Single,
+ bool isMainDom = true,
+ Mock mockEventAggregator = null)
+ {
+ var mockRunTimeState = new Mock();
+ mockRunTimeState.SetupGet(x => x.Level).Returns(runtimeLevel);
+
+ var mockServerRegistrar = new Mock();
+ mockServerRegistrar.Setup(x => x.CurrentServerRole).Returns(serverRole);
+
+ var mockMainDom = new Mock();
+ mockMainDom.SetupGet(x => x.IsMainDom).Returns(isMainDom);
+
+ var mockLogger = new Mock>>();
+ if (mockEventAggregator == null)
+ {
+ mockEventAggregator = new Mock();
+ }
+
+ return new RecurringBackgroundJobHostedService(
+ mockRunTimeState.Object,
+ mockLogger.Object,
+ mockMainDom.Object,
+ mockServerRegistrar.Object,
+ mockEventAggregator.Object,
+ mockJob.Object);
+ }
+}
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/HealthCheckNotifierTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/HealthCheckNotifierTests.cs
index 626129b3b7..03d7f344a6 100644
--- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/HealthCheckNotifierTests.cs
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/HealthCheckNotifierTests.cs
@@ -23,6 +23,7 @@ using Umbraco.Cms.Tests.Common;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HostedServices;
[TestFixture]
+[Obsolete("Replaced by BackgroundJobs.Jobs.HealthCheckNotifierJobTests")]
public class HealthCheckNotifierTests
{
private Mock _mockNotificationMethod;
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/KeepAliveTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/KeepAliveTests.cs
index f0ef4cd278..4631bb21a1 100644
--- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/KeepAliveTests.cs
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/KeepAliveTests.cs
@@ -21,6 +21,7 @@ using Umbraco.Cms.Tests.Common;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HostedServices;
[TestFixture]
+[Obsolete("Replaced by BackgroundJobs.Jobs.KeepAliveJobTests")]
public class KeepAliveTests
{
private Mock _mockHttpMessageHandler;
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/LogScrubberTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/LogScrubberTests.cs
index 553a4f451c..98fdf4c453 100644
--- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/LogScrubberTests.cs
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/LogScrubberTests.cs
@@ -19,6 +19,7 @@ using Umbraco.Cms.Tests.Common;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HostedServices;
[TestFixture]
+[Obsolete("Replaced by BackgroundJobs.Jobs.LogScrubberJobTests")]
public class LogScrubberTests
{
private Mock _mockAuditService;
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ScheduledPublishingTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ScheduledPublishingTests.cs
index 609dfbb7fa..3eb7756d7f 100644
--- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ScheduledPublishingTests.cs
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ScheduledPublishingTests.cs
@@ -19,6 +19,7 @@ using Umbraco.Cms.Infrastructure.HostedServices;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HostedServices;
[TestFixture]
+[Obsolete("Replaced by BackgroundJobs.Jobs.ScheduledPublishingJobTests")]
public class ScheduledPublishingTests
{
private Mock _mockContentService;
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTaskTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTaskTests.cs
index fd24d60019..1513c6a5d4 100644
--- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTaskTests.cs
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTaskTests.cs
@@ -15,6 +15,7 @@ using Umbraco.Cms.Infrastructure.HostedServices.ServerRegistration;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HostedServices.ServerRegistration;
[TestFixture]
+[Obsolete("Replaced by BackgroundJobs.Jobs.ServerRegistration.InstructionProcessJobTests")]
public class InstructionProcessTaskTests
{
private Mock _mockDatabaseServerMessenger;
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTaskTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTaskTests.cs
index b379f8d34b..91d156e519 100644
--- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTaskTests.cs
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTaskTests.cs
@@ -16,6 +16,7 @@ using Umbraco.Cms.Tests.Common;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HostedServices.ServerRegistration;
[TestFixture]
+[Obsolete("Replaced by BackgroundJobs.Jobs.ServerRegistration.TouchServerJobTests")]
public class TouchServerTaskTests
{
private Mock _mockServerRegistrationService;
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/TempFileCleanupTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/TempFileCleanupTests.cs
index 851afc269b..2128f917b9 100644
--- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/TempFileCleanupTests.cs
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/TempFileCleanupTests.cs
@@ -13,6 +13,7 @@ using Umbraco.Cms.Infrastructure.HostedServices;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HostedServices
{
[TestFixture]
+ [Obsolete("Replaced by BackgroundJobs.Jobs.TempFileCleanupTests")]
public class TempFileCleanupTests
{
private Mock _mockIOHelper;