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:
co-authored by
Copilot Autofix powered by AI
parent
86abc3528d
commit
a5b7e0dac1
@@ -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;
|
||||
}
|
||||
+43
@@ -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>
|
||||
|
||||
+10
-2
@@ -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>();
|
||||
|
||||
+70
-15
@@ -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;
|
||||
}
|
||||
|
||||
+85
@@ -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);
|
||||
}
|
||||
+25
-6
@@ -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());
|
||||
|
||||
+98
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user