Scheduled publishing: Add configurable period and optional clock-aligned scheduling (#23127)

* Add configurable period for scheduled publishing task with optional clock alignment.

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Addressed code review comments.

* Clarified the maths, improved comments and test coverage.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Andy Butland
2026-06-15 13:48:34 +00:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent 86abc3528d
commit a5b7e0dac1
12 changed files with 448 additions and 27 deletions
@@ -0,0 +1,33 @@
using System.ComponentModel;
namespace Umbraco.Cms.Core.Configuration.Models;
/// <summary>
/// Settings for scheduled publishing.
/// </summary>
[UmbracoOptions(Constants.Configuration.ConfigScheduledPublishing)]
public class ScheduledPublishingSettings
{
private const string StaticPeriod = "00:01:00";
private const bool StaticAlignToClock = false;
/// <summary>
/// Gets or sets a value for how often scheduled publishing runs.
/// </summary>
[DefaultValue(StaticPeriod)]
public TimeSpan Period { get; set; } = TimeSpan.Parse(StaticPeriod);
/// <summary>
/// Gets or sets a value indicating whether scheduled publishing runs are aligned to clock boundaries
/// derived from <see cref="Period" /> (for example, on the minute, or every N seconds), rather than drifting
/// based on when the previous run completed.
/// </summary>
/// <remarks>
/// When enabled, <see cref="Period" /> must be a whole number of seconds that divides evenly into one hour
/// (for example 10, 12, 15, 20, 30 or 60 seconds) so that boundaries land on consistent clock times.
/// Boundaries are anchored to <strong>UTC</strong>, not the server's local time zone; for sub-minute and
/// whole-minute periods this is indistinguishable from local time at the second level.
/// </remarks>
[DefaultValue(StaticAlignToClock)]
public bool AlignToClock { get; set; } = StaticAlignToClock;
}
@@ -0,0 +1,43 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Options;
namespace Umbraco.Cms.Core.Configuration.Models.Validation;
/// <summary>
/// Validator for configuration represented as <see cref="ScheduledPublishingSettings" />.
/// </summary>
public class ScheduledPublishingSettingsValidator : ConfigurationValidatorBase, IValidateOptions<ScheduledPublishingSettings>
{
/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, ScheduledPublishingSettings options)
{
if (options.Period <= TimeSpan.Zero)
{
return ValidateOptionsResult.Fail(
$"Configuration entry {Constants.Configuration.ConfigScheduledPublishing}:Period must be greater than zero.");
}
if (options.AlignToClock && IsCleanDivisorOfAnHour(options.Period) == false)
{
return ValidateOptionsResult.Fail(
$"Configuration entry {Constants.Configuration.ConfigScheduledPublishing}:Period must be a whole number of seconds that divides evenly into one hour (3600 seconds) when {Constants.Configuration.ConfigScheduledPublishing}:AlignToClock is enabled, e.g. 10, 12, 15, 20, 30 or 60 seconds.");
}
return ValidateOptionsResult.Success;
}
private static bool IsCleanDivisorOfAnHour(TimeSpan period)
{
var totalSeconds = period.TotalSeconds;
// Must be a positive, whole number of seconds (no sub-second component).
if (totalSeconds <= 0 || totalSeconds != Math.Floor(totalSeconds))
{
return false;
}
return 3600 % (long)totalSeconds == 0;
}
}
@@ -291,6 +291,11 @@ public static partial class Constants
/// </summary>
public const string ConfigDistributedJobs = ConfigPrefix + "DistributedJobs";
/// <summary>
/// The configuration key for scheduled publishing settings.
/// </summary>
public const string ConfigScheduledPublishing = ConfigPrefix + "ScheduledPublishing";
/// <summary>
/// The configuration key for backoffice token cookie settings.
/// </summary>
@@ -57,6 +57,7 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<IValidateOptions<RequestHandlerSettings>, RequestHandlerSettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<UnattendedSettings>, UnattendedSettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<SecuritySettings>, SecuritySettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<ScheduledPublishingSettings>, ScheduledPublishingSettingsValidator>();
// Register configuration sections.
// TODO (V18): Remove the registrations of UserPasswordConfigurationSettings and MemberPasswordConfigurationSettings.
@@ -102,6 +103,7 @@ public static partial class UmbracoBuilderExtensions
.AddUmbracoOptions<CacheSettings>()
.AddUmbracoOptions<SystemDateMigrationSettings>()
.AddUmbracoOptions<DistributedJobSettings>()
.AddUmbracoOptions<ScheduledPublishingSettings>(options => options.ValidateOnStart())
.AddUmbracoOptions<BackOfficeTokenCookieSettings>()
.AddUmbracoOptions<WebsiteSettings>()
.AddUmbracoOptions<SignalRSettings>();
@@ -1,4 +1,4 @@
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// A background job that will be executed by an available server. With a single server setup this will always be the same.
@@ -16,6 +16,19 @@ public interface IDistributedBackgroundJob
/// </summary>
TimeSpan Period { get; }
/// <summary>
/// Gets a value indicating whether the job's runs should be aligned to clock boundaries derived from <see cref="Period" />.
/// </summary>
/// <remarks>
/// When <c>true</c>, the job becomes runnable on the next clock boundary that is a multiple of <see cref="Period" />
/// (measured from a fixed <strong>UTC</strong> origin, so boundaries fall on round clock times such as on the minute
/// or every N seconds) rather than at <c>LastRun + Period</c>.
/// For predictable boundaries <see cref="Period" /> should divide evenly into one hour.
/// The scheduler may cache this value when it first evaluates registered jobs; changing it at runtime may require an application restart.
/// Defaults to <c>false</c>, preserving the original drift-from-completion behaviour.
/// </remarks>
bool AlignToClock => false;
/// <summary>
/// Run the job.
/// </summary>
@@ -2,7 +2,9 @@
// 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.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
@@ -22,7 +24,10 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
public string Name => "ScheduledPublishingJob";
/// <inheritdoc />
public TimeSpan Period => TimeSpan.FromMinutes(1);
public TimeSpan Period => _scheduledPublishingSettings.CurrentValue.Period;
/// <inheritdoc />
public bool AlignToClock => _scheduledPublishingSettings.CurrentValue.AlignToClock;
private readonly IContentService _contentService;
@@ -31,6 +36,7 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
private readonly TimeProvider _timeProvider;
private readonly IServerMessenger _serverMessenger;
private readonly IUmbracoContextFactory _umbracoContextFactory;
private readonly IOptionsMonitor<ScheduledPublishingSettings> _scheduledPublishingSettings;
/// <summary>
/// Initializes a new instance of the <see cref="ScheduledPublishingJob" /> class.
@@ -41,7 +47,8 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
ILogger<ScheduledPublishingJob> logger,
IServerMessenger serverMessenger,
ICoreScopeProvider scopeProvider,
TimeProvider timeProvider)
TimeProvider timeProvider,
IOptionsMonitor<ScheduledPublishingSettings> scheduledPublishingSettings)
{
_contentService = contentService;
_umbracoContextFactory = umbracoContextFactory;
@@ -49,6 +56,7 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
_serverMessenger = serverMessenger;
_scopeProvider = scopeProvider;
_timeProvider = timeProvider;
_scheduledPublishingSettings = scheduledPublishingSettings;
}
/// <inheritdoc />
@@ -20,6 +20,10 @@ public class DistributedJobService : IDistributedJobService
private readonly ILogger<DistributedJobService> _logger;
private readonly DistributedJobSettings _settings;
// Which jobs align to the clock is a startup configuration concern (changing it requires a restart), so it is
// captured once in the constructor rather than re-evaluated on every poll.
private readonly HashSet<string> _clockAlignedJobNames;
/// <summary>
/// Initializes a new instance of the <see cref="DistributedJobService"/> class.
/// </summary>
@@ -58,6 +62,10 @@ public class DistributedJobService : IDistributedJobService
_distributedBackgroundJobs = distributedBackgroundJobs;
_logger = logger;
_settings = settings.Value;
_clockAlignedJobNames = _distributedBackgroundJobs
.Where(x => x.AlignToClock)
.Select(x => x.Name)
.ToHashSet();
}
/// <inheritdoc />
@@ -67,9 +75,12 @@ public class DistributedJobService : IDistributedJobService
scope.EagerWriteLock(Constants.Locks.DistributedJobs);
DateTime utcNow = DateTime.UtcNow;
IEnumerable<DistributedBackgroundJobModel> jobs = _distributedJobRepository.GetAll();
DistributedBackgroundJobModel? job = jobs.FirstOrDefault(x => x.LastRun < DateTime.UtcNow - x.Period
&& (x.IsRunning is false || x.LastAttemptedRun < DateTime.UtcNow - x.Period - _settings.MaximumExecutionTime));
DistributedBackgroundJobModel? job = jobs.FirstOrDefault(x =>
IsDue(x, utcNow, _clockAlignedJobNames.Contains(x.Name))
&& (x.IsRunning is false || x.LastAttemptedRun < utcNow - x.Period - _settings.MaximumExecutionTime));
if (job is null)
{
@@ -97,6 +108,39 @@ public class DistributedJobService : IDistributedJobService
return distributedJob;
}
/// <summary>
/// Determines whether a job is due to run.
/// </summary>
/// <param name="job">The job state.</param>
/// <param name="utcNow">The current UTC time.</param>
/// <param name="aligned">
/// Whether the job's runs are aligned to clock boundaries (see <see cref="IDistributedBackgroundJob.AlignToClock" />).
/// </param>
/// <remarks>
/// For non-aligned jobs the period counts from the previous run's completion (<c>LastRun + Period</c>, drifting).
/// For aligned jobs the job is due once a clock boundary — a multiple of the period measured from a fixed UTC
/// origin, so boundaries fall on round clock times such as on the minute — has fallen strictly after the previous
/// run's completion. Boundaries are in UTC, not the server's local time zone. This is overrun-safe: if a run takes
/// longer than the period, the boundary it would have targeted has already passed, so the missed boundary is
/// skipped rather than triggering back-to-back runs.
/// </remarks>
internal static bool IsDue(DistributedBackgroundJobModel job, DateTime utcNow, bool aligned)
{
if (aligned == false || job.Period <= TimeSpan.Zero)
{
return job.LastRun < utcNow - job.Period;
}
long periodTicks = job.Period.Ticks;
// Floor the current UTC time to the most recent clock boundary. Ticks count from a fixed origin (0001-01-01), and
// a day divides evenly by any clean sub-hour period, so boundaries fall on round clock times (e.g. each :10s).
long ticksSinceBoundary = utcNow.Ticks % periodTicks;
long currentBoundaryTicks = utcNow.Ticks - ticksSinceBoundary;
return currentBoundaryTicks > job.LastRun.Ticks;
}
/// <inheritdoc />
public async Task FinishAsync(string jobName)
{
@@ -136,11 +180,25 @@ public class DistributedJobService : IDistributedJobService
return;
}
// Clock-aligned jobs only hit their boundaries as tightly as the poll interval allows. If the poll interval
// is longer than the job's period, boundaries between polls are silently missed.
foreach (IDistributedBackgroundJob job in _distributedBackgroundJobs)
{
if (job.AlignToClock && job.Period < _settings.Period)
{
_logger.LogWarning(
"Distributed background job '{JobName}' aligns to the clock with a period of {Period}, but the distributed job poll interval is longer ({PollInterval}). Clock boundaries shorter than the poll interval will be missed; set Umbraco:CMS:DistributedJobs:Period to be no longer than the job period.",
job.Name,
job.Period,
_settings.Period);
}
}
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
scope.WriteLock(Constants.Locks.DistributedJobs);
DistributedBackgroundJobModel[] existingJobs = _distributedJobRepository.GetAll().ToArray();
var existingJobsByName = existingJobs.ToDictionary(x => x.Name);
Dictionary<string, DistributedBackgroundJobModel> existingJobsByName = existingJobs.ToDictionary(x => x.Name);
// Collect all changes first, then execute - minimizes time spent in the critical section
var jobsToAdd = new List<DistributedBackgroundJobModel>();
@@ -17,20 +17,27 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services;
internal sealed class DistributedJobServiceTests : UmbracoIntegrationTest
{
private const string TestJobName = "TestDistributedJob";
private static readonly TimeSpan TestJobPeriod = TimeSpan.FromMinutes(5);
private static readonly TimeSpan TestMaxExecutionTime = TimeSpan.FromMinutes(10);
private const string AlignedTestJobName = "AlignedTestDistributedJob";
private static readonly TimeSpan _testJobPeriod = TimeSpan.FromMinutes(5);
// A long, clock-divisible period (boundaries on the hour) keeps the aligned tests robust against running
// near a boundary — the chance of crossing a top-of-hour mid-test is negligible.
private static readonly TimeSpan _alignedTestJobPeriod = TimeSpan.FromHours(1);
private static readonly TimeSpan _testMaxExecutionTime = TimeSpan.FromMinutes(10);
private IDistributedJobService DistributedJobService => GetRequiredService<IDistributedJobService>();
protected override void CustomTestSetup(IUmbracoBuilder builder)
{
// Register a test job
// Register a test job and a clock-aligned test job
builder.Services.AddSingleton<IDistributedBackgroundJob, TestDistributedJob>();
builder.Services.AddSingleton<IDistributedBackgroundJob, AlignedTestDistributedJob>();
// Configure settings with a known MaximumExecutionTime for testing
builder.Services.PostConfigure<DistributedJobSettings>(options =>
{
options.MaximumExecutionTime = TestMaxExecutionTime;
options.MaximumExecutionTime = _testMaxExecutionTime;
});
}
@@ -41,7 +48,7 @@ internal sealed class DistributedJobServiceTests : UmbracoIntegrationTest
await DistributedJobService.EnsureJobsAsync();
// Set the job's LastRun to be older than the period
SetJobState(TestJobName, lastRun: DateTime.UtcNow - TestJobPeriod - TimeSpan.FromMinutes(1), isRunning: false);
SetJobState(TestJobName, lastRun: DateTime.UtcNow - _testJobPeriod - TimeSpan.FromMinutes(1), isRunning: false);
// Act
var job = await DistributedJobService.TryTakeRunnableAsync();
@@ -80,7 +87,7 @@ internal sealed class DistributedJobServiceTests : UmbracoIntegrationTest
// Set the job as running with a recent LastAttemptedRun (not timed out)
SetJobState(
TestJobName,
lastRun: DateTime.UtcNow - TestJobPeriod - TimeSpan.FromMinutes(1),
lastRun: DateTime.UtcNow - _testJobPeriod - TimeSpan.FromMinutes(1),
isRunning: true,
lastAttemptedRun: DateTime.UtcNow);
@@ -98,7 +105,7 @@ internal sealed class DistributedJobServiceTests : UmbracoIntegrationTest
await DistributedJobService.EnsureJobsAsync();
// Set the job as running but with LastAttemptedRun older than Period + MaxExecutionTime (timed out)
var timedOutTime = DateTime.UtcNow - TestJobPeriod - TestMaxExecutionTime - TimeSpan.FromMinutes(1);
var timedOutTime = DateTime.UtcNow - _testJobPeriod - _testMaxExecutionTime - TimeSpan.FromMinutes(1);
SetJobState(
TestJobName,
lastRun: timedOutTime - TimeSpan.FromMinutes(1),
@@ -118,7 +125,7 @@ internal sealed class DistributedJobServiceTests : UmbracoIntegrationTest
{
// Arrange - Ensure jobs are registered and take a job
await DistributedJobService.EnsureJobsAsync();
SetJobState(TestJobName, lastRun: DateTime.UtcNow - TestJobPeriod - TimeSpan.FromMinutes(1), isRunning: false);
SetJobState(TestJobName, lastRun: DateTime.UtcNow - _testJobPeriod - TimeSpan.FromMinutes(1), isRunning: false);
var job = await DistributedJobService.TryTakeRunnableAsync();
Assert.IsNotNull(job);
@@ -144,7 +151,7 @@ internal sealed class DistributedJobServiceTests : UmbracoIntegrationTest
// Assert
var jobState = GetJobState(TestJobName);
Assert.IsNotNull(jobState);
Assert.AreEqual(TestJobPeriod.Ticks, jobState.Period);
Assert.AreEqual(_testJobPeriod.Ticks, jobState.Period);
}
[Test]
@@ -154,7 +161,7 @@ internal sealed class DistributedJobServiceTests : UmbracoIntegrationTest
await DistributedJobService.EnsureJobsAsync();
// Set the job's LastRun to be older than the period
SetJobState(TestJobName, lastRun: DateTime.UtcNow - TestJobPeriod - TimeSpan.FromMinutes(1), isRunning: false);
SetJobState(TestJobName, lastRun: DateTime.UtcNow - _testJobPeriod - TimeSpan.FromMinutes(1), isRunning: false);
// Act - Take the first job
var job1 = await DistributedJobService.TryTakeRunnableAsync();
@@ -175,10 +182,10 @@ internal sealed class DistributedJobServiceTests : UmbracoIntegrationTest
// Set the job as running with LastAttemptedRun just slightly before the timeout threshold
// This tests the boundary condition
var justBeforeTimeout = DateTime.UtcNow - TestJobPeriod - TestMaxExecutionTime + TimeSpan.FromSeconds(30);
var justBeforeTimeout = DateTime.UtcNow - _testJobPeriod - _testMaxExecutionTime + TimeSpan.FromSeconds(30);
SetJobState(
TestJobName,
lastRun: justBeforeTimeout - TestJobPeriod,
lastRun: justBeforeTimeout - _testJobPeriod,
isRunning: true,
lastAttemptedRun: justBeforeTimeout);
@@ -218,7 +225,7 @@ internal sealed class DistributedJobServiceTests : UmbracoIntegrationTest
await DistributedJobService.EnsureJobsAsync();
// Manually change the period in the database to simulate a mismatch
var originalPeriod = TestJobPeriod;
var originalPeriod = _testJobPeriod;
var differentPeriod = TimeSpan.FromHours(99);
UpdateJobPeriod(TestJobName, differentPeriod);
@@ -250,7 +257,41 @@ internal sealed class DistributedJobServiceTests : UmbracoIntegrationTest
// Assert - Job should still exist with same properties
Assert.AreEqual(afterFirst.Id, afterSecond.Id);
Assert.AreEqual(afterSecond.Id, afterThird.Id);
Assert.AreEqual(TestJobPeriod.Ticks, afterThird.Period);
Assert.AreEqual(_testJobPeriod.Ticks, afterThird.Period);
}
[Test]
public async Task TryTakeRunnableAsync_AlignedJobBoundaryPassedSinceLastRun_ReturnsJob()
{
// Arrange
await DistributedJobService.EnsureJobsAsync();
// A 1-hour-aligned job whose last run was two hours ago: at least one top-of-hour boundary has passed since.
SetJobState(AlignedTestJobName, lastRun: DateTime.UtcNow - TimeSpan.FromHours(2), isRunning: false);
// Act
var job = await DistributedJobService.TryTakeRunnableAsync();
// Assert - only the aligned job is due (all other jobs were just seeded with LastRun = now)
Assert.IsNotNull(job);
Assert.AreEqual(AlignedTestJobName, job!.Name);
}
[Test]
public async Task TryTakeRunnableAsync_AlignedJobNoBoundarySinceLastRun_ReturnsNull()
{
// Arrange
await DistributedJobService.EnsureJobsAsync();
// Last run is set slightly in the future so the most recent clock boundary is guaranteed to be before it,
// even if the wall clock crosses a top-of-hour between here and the poll (avoids a boundary race).
SetJobState(AlignedTestJobName, lastRun: DateTime.UtcNow.AddMinutes(1), isRunning: false);
// Act
var job = await DistributedJobService.TryTakeRunnableAsync();
// Assert
Assert.IsNull(job);
}
private void SetJobState(string jobName, DateTime lastRun, bool isRunning, DateTime? lastAttemptedRun = null)
@@ -326,7 +367,21 @@ internal sealed class DistributedJobServiceTests : UmbracoIntegrationTest
{
public string Name => TestJobName;
public TimeSpan Period => TestJobPeriod;
public TimeSpan Period => _testJobPeriod;
public Task ExecuteAsync() => Task.CompletedTask;
}
/// <summary>
/// A clock-aligned test implementation of <see cref="IDistributedBackgroundJob"/>.
/// </summary>
private sealed class AlignedTestDistributedJob : IDistributedBackgroundJob
{
public string Name => AlignedTestJobName;
public TimeSpan Period => _alignedTestJobPeriod;
public bool AlignToClock => true;
public Task ExecuteAsync() => Task.CompletedTask;
}
@@ -0,0 +1,85 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using NUnit.Framework;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Configuration.Models.Validation;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models.Validation;
[TestFixture]
public class ScheduledPublishingSettingsValidatorTests
{
[Test]
public void Can_Validate_Default_Configuration()
{
var result = Validate(new ScheduledPublishingSettings());
Assert.True(result.Succeeded);
}
[TestCase(0)]
[TestCase(-5)]
public void Cannot_Validate_Zero_Or_Negative_Period(int seconds)
{
var result = Validate(new ScheduledPublishingSettings { Period = TimeSpan.FromSeconds(seconds) });
Assert.False(result.Succeeded);
}
[Test]
public void Can_Validate_Any_Positive_Period_When_Not_Aligned()
{
// 7 seconds does not divide evenly into an hour, but that's only enforced when aligned.
var result = Validate(new ScheduledPublishingSettings
{
Period = TimeSpan.FromSeconds(7),
AlignToClock = false,
});
Assert.True(result.Succeeded);
}
[TestCase(1)]
[TestCase(10)]
[TestCase(12)]
[TestCase(15)]
[TestCase(20)]
[TestCase(30)]
[TestCase(60)]
[TestCase(3600)]
public void Can_Validate_Clean_Divisor_Of_An_Hour_When_Aligned(int seconds)
{
var result = Validate(new ScheduledPublishingSettings
{
Period = TimeSpan.FromSeconds(seconds),
AlignToClock = true,
});
Assert.True(result.Succeeded);
}
[TestCase(7)]
[TestCase(11)]
[TestCase(70)]
[TestCase(7200)]
public void Cannot_Validate_Non_Divisor_Of_An_Hour_When_Aligned(int seconds)
{
var result = Validate(new ScheduledPublishingSettings
{
Period = TimeSpan.FromSeconds(seconds),
AlignToClock = true,
});
Assert.False(result.Succeeded);
}
[Test]
public void Cannot_Validate_Sub_Second_Period_When_Aligned()
{
var result = Validate(new ScheduledPublishingSettings
{
Period = TimeSpan.FromMilliseconds(500),
AlignToClock = true,
});
Assert.False(result.Succeeded);
}
private static Microsoft.Extensions.Options.ValidateOptionsResult Validate(ScheduledPublishingSettings options)
=> new ScheduledPublishingSettingsValidator().Validate("settings", options);
}
@@ -2,21 +2,19 @@
// See LICENSE for more details.
using System.Data;
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.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.BackgroundJobs.Jobs.DistributedJobs;
using Umbraco.Cms.Infrastructure.HostedServices;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs.Jobs;
@@ -42,8 +40,25 @@ public class ScheduledPublishingJobTests
VerifyScheduledPublishingPerformed();
}
[Test]
public void Period_And_AlignToClock_Reflect_Configured_Settings()
{
var sut = CreateScheduledPublishing(settings: new ScheduledPublishingSettings
{
Period = TimeSpan.FromSeconds(10),
AlignToClock = true,
});
Assert.Multiple(() =>
{
Assert.AreEqual(TimeSpan.FromSeconds(10), sut.Period);
Assert.IsTrue(sut.AlignToClock);
});
}
private ScheduledPublishingJob CreateScheduledPublishing(
bool enabled = true)
bool enabled = true,
ScheduledPublishingSettings? settings = null)
{
if (enabled)
{
@@ -76,13 +91,17 @@ public class ScheduledPublishingJobTests
It.IsAny<bool>()))
.Returns(Mock.Of<IScope>());
var scheduledPublishingSettings =
Mock.Of<IOptionsMonitor<ScheduledPublishingSettings>>(x => x.CurrentValue == (settings ?? new ScheduledPublishingSettings()));
return new ScheduledPublishingJob(
_mockContentService.Object,
mockUmbracoContextFactory.Object,
_mockLogger.Object,
mockServerMessenger.Object,
mockScopeProvider.Object,
TimeProvider.System);
TimeProvider.System,
scheduledPublishingSettings);
}
private void VerifyScheduledPublishingNotPerformed() => VerifyScheduledPublishingPerformed(Times.Never());
@@ -0,0 +1,98 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using NUnit.Framework;
using Umbraco.Cms.Infrastructure.Models;
using Umbraco.Cms.Infrastructure.Services.Implement;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Services;
/// <summary>
/// Unit tests for <see cref="DistributedJobService" />.
/// </summary>
/// <remarks>
/// Covers <see cref="DistributedJobService.IsDue" />, which decides when a distributed background job is runnable —
/// both the default drift-from-completion behaviour and the opt-in clock-aligned behaviour.
/// </remarks>
[TestFixture]
public class DistributedJobServiceTests
{
// 2026-01-01 10:00:00 UTC sits exactly on a 10-second clock boundary (anchored at the epoch).
private static readonly DateTime _onBoundary = new(2026, 1, 1, 10, 0, 0, DateTimeKind.Utc);
private static DistributedBackgroundJobModel Job(TimeSpan period, DateTime lastRun)
=> new() { Name = "Test", Period = period, LastRun = lastRun };
[Test]
public void Can_Run_NonAligned_Job_When_Period_Has_Elapsed_Since_Last_Run()
{
DateTime now = _onBoundary;
var job = Job(TimeSpan.FromMinutes(1), now.AddSeconds(-61));
Assert.IsTrue(DistributedJobService.IsDue(job, now, aligned: false));
}
[Test]
public void Cannot_Run_NonAligned_Job_Before_Period_Has_Elapsed()
{
DateTime now = _onBoundary;
var job = Job(TimeSpan.FromMinutes(1), now.AddSeconds(-59));
Assert.IsFalse(DistributedJobService.IsDue(job, now, aligned: false));
}
[Test]
public void Can_Run_Aligned_Job_When_A_Boundary_Has_Passed_Since_Last_Run()
{
// now is on the :00 boundary, last run was 5s earlier (within the previous period).
var job = Job(TimeSpan.FromSeconds(10), _onBoundary.AddSeconds(-5));
Assert.IsTrue(DistributedJobService.IsDue(job, _onBoundary, aligned: true));
}
[Test]
public void Cannot_Run_Aligned_Job_Between_Boundaries()
{
// Ran on the :00 boundary; at :07 the most recent boundary is still :00 -> not due until :10.
var job = Job(TimeSpan.FromSeconds(10), _onBoundary);
Assert.IsFalse(DistributedJobService.IsDue(job, _onBoundary.AddSeconds(7), aligned: true));
}
[Test]
public void Can_Run_Aligned_Job_At_The_Next_Boundary()
{
var job = Job(TimeSpan.FromSeconds(10), _onBoundary);
Assert.IsTrue(DistributedJobService.IsDue(job, _onBoundary.AddSeconds(10), aligned: true));
}
[Test]
public void Cannot_Run_Aligned_Job_On_Boundary_Missed_During_Overrun()
{
// A run that started on the :00 boundary overran and finished at :15 (period is 10s).
var job = Job(TimeSpan.FromSeconds(10), _onBoundary.AddSeconds(15));
// At :18 the most recent boundary (:10) is before the finish (:15) -> not due (no back-to-back catch-up).
Assert.IsFalse(DistributedJobService.IsDue(job, _onBoundary.AddSeconds(18), aligned: true));
}
[Test]
public void Can_Run_Aligned_Job_At_First_Boundary_After_Overrun()
{
// A run that started on the :00 boundary overran and finished at :15 (period is 10s).
var job = Job(TimeSpan.FromSeconds(10), _onBoundary.AddSeconds(15));
// At :20 a fresh boundary has passed after the finish -> due.
Assert.IsTrue(DistributedJobService.IsDue(job, _onBoundary.AddSeconds(20), aligned: true));
}
[Test]
public void Can_Run_Aligned_Job_With_Zero_Period_Without_Dividing_By_Zero()
{
// A non-positive period must not divide by zero; alignment falls back to the drift rule (LastRun < now - period).
var job = Job(TimeSpan.Zero, _onBoundary.AddSeconds(-5));
Assert.IsTrue(DistributedJobService.IsDue(job, _onBoundary, aligned: true));
}
}
@@ -78,6 +78,8 @@ internal sealed class UmbracoCmsSchema
public required DistributedJobSettings DistributedJobSettings { get; set; }
public required ScheduledPublishingSettings ScheduledPublishing { get; set; }
public required WebsiteSettings Website { get; set; }
public required SignalRSettings SignalR { get; set; }