diff --git a/src/Trax.Scheduler/Configuration/ManifestOptions.cs b/src/Trax.Scheduler/Configuration/ManifestOptions.cs index 3e3300e..3ed87c3 100644 --- a/src/Trax.Scheduler/Configuration/ManifestOptions.cs +++ b/src/Trax.Scheduler/Configuration/ManifestOptions.cs @@ -1,4 +1,5 @@ using Trax.Effect.Enums; +using Trax.Effect.Models.Manifest; namespace Trax.Scheduler.Configuration; @@ -87,4 +88,14 @@ public class ManifestOptions /// Null means use the global default from SchedulerConfiguration.DefaultMisfireThreshold. /// public TimeSpan? MisfireThreshold { get; set; } + + /// + /// Gets or sets the exclusion windows for this manifest. + /// + /// + /// When any exclusion matches the current time, the manifest is not scheduled. + /// Excluded periods are treated as "intentionally skipped", not as misfires. + /// Empty list means no exclusions. + /// + public List Exclusions { get; set; } = []; } diff --git a/src/Trax.Scheduler/Configuration/ScheduleOptions.cs b/src/Trax.Scheduler/Configuration/ScheduleOptions.cs index 3b3d518..9234ed1 100644 --- a/src/Trax.Scheduler/Configuration/ScheduleOptions.cs +++ b/src/Trax.Scheduler/Configuration/ScheduleOptions.cs @@ -1,4 +1,5 @@ using Trax.Effect.Enums; +using Trax.Effect.Models.Manifest; namespace Trax.Scheduler.Configuration; @@ -31,6 +32,7 @@ public class ScheduleOptions internal bool _isDormant; internal MisfirePolicy? _misfirePolicy; internal TimeSpan? _misfireThreshold; + internal List _exclusions = []; // Group-level state internal string? _groupId; @@ -121,6 +123,21 @@ public ScheduleOptions MisfireThreshold(TimeSpan threshold) return this; } + /// + /// Adds an exclusion window to this manifest. The manifest will not be scheduled + /// during any period matched by the exclusion. + /// + /// + /// Multiple exclusions can be added. If ANY exclusion matches the current time, + /// the manifest is skipped. Excluded periods are treated as "intentionally skipped" + /// — not as misfires. + /// + public ScheduleOptions Exclude(Exclusion exclusion) + { + _exclusions.Add(exclusion); + return this; + } + // ── Group-level fluent methods ──────────────────────────────────── /// @@ -177,5 +194,6 @@ internal ManifestOptions ToManifestOptions() => IsDormant = _isDormant, MisfirePolicy = _misfirePolicy, MisfireThreshold = _misfireThreshold, + Exclusions = _exclusions, }; } diff --git a/src/Trax.Scheduler/Extensions/DataContextExtensions.cs b/src/Trax.Scheduler/Extensions/DataContextExtensions.cs index ec5685d..934ebc8 100644 --- a/src/Trax.Scheduler/Extensions/DataContextExtensions.cs +++ b/src/Trax.Scheduler/Extensions/DataContextExtensions.cs @@ -131,6 +131,7 @@ internal static async Task UpsertManifestAsync( existing.Priority = options.Priority; ApplySchedule(existing, schedule); ApplyMisfireOptions(existing, options); + ApplyExclusions(existing, options); return existing; } @@ -151,6 +152,7 @@ internal static async Task UpsertManifestAsync( manifest.SetProperties(input); ApplySchedule(manifest, schedule); ApplyMisfireOptions(manifest, options); + ApplyExclusions(manifest, options); context.Manifests.Add(manifest); @@ -237,6 +239,7 @@ internal static async Task UpsertDependentManifestAsync( existing.CronExpression = null; existing.IntervalSeconds = null; ApplyMisfireOptions(existing, options); + ApplyExclusions(existing, options); return existing; } @@ -257,6 +260,7 @@ internal static async Task UpsertDependentManifestAsync( }; manifest.SetProperties(input); ApplyMisfireOptions(manifest, options); + ApplyExclusions(manifest, options); context.Manifests.Add(manifest); @@ -339,6 +343,7 @@ internal static async Task UpsertOnceManifestAsync( existing.CronExpression = null; existing.IntervalSeconds = null; ApplyMisfireOptions(existing, options); + ApplyExclusions(existing, options); return existing; } @@ -359,6 +364,7 @@ internal static async Task UpsertOnceManifestAsync( }; manifest.SetProperties(input); ApplyMisfireOptions(manifest, options); + ApplyExclusions(manifest, options); context.Manifests.Add(manifest); @@ -389,4 +395,12 @@ private static void ApplyMisfireOptions(Manifest manifest, ManifestOptions optio ? (int)options.MisfireThreshold.Value.TotalSeconds : null; } + + /// + /// Applies exclusion window configuration to a manifest from the options. + /// + private static void ApplyExclusions(Manifest manifest, ManifestOptions options) + { + manifest.SetExclusions(options.Exclusions); + } } diff --git a/src/Trax.Scheduler/Services/ManifestScheduler/ManifestScheduler.cs b/src/Trax.Scheduler/Services/ManifestScheduler/ManifestScheduler.cs index 2d3e218..674cc66 100644 --- a/src/Trax.Scheduler/Services/ManifestScheduler/ManifestScheduler.cs +++ b/src/Trax.Scheduler/Services/ManifestScheduler/ManifestScheduler.cs @@ -887,6 +887,7 @@ private static ManifestOptions CreateItemOptions(ManifestOptions baseOptions) => MaxRetries = baseOptions.MaxRetries, Timeout = baseOptions.Timeout, IsDormant = baseOptions.IsDormant, + Exclusions = baseOptions.Exclusions, }; private static async Task GetManifestByExternalIdAsync( diff --git a/src/Trax.Scheduler/Trains/ManifestManager/Utilities/SchedulingHelpers.cs b/src/Trax.Scheduler/Trains/ManifestManager/Utilities/SchedulingHelpers.cs index 37544cb..37a2c74 100644 --- a/src/Trax.Scheduler/Trains/ManifestManager/Utilities/SchedulingHelpers.cs +++ b/src/Trax.Scheduler/Trains/ManifestManager/Utilities/SchedulingHelpers.cs @@ -26,6 +26,11 @@ public static bool ShouldRunNow( ILogger logger ) { + // Check exclusions first — if the current time falls within any exclusion, + // skip this manifest. Excluded periods are "intentionally skipped", not misfires. + if (IsExcluded(manifest, now, logger)) + return false; + return manifest.ScheduleType switch { ScheduleType.Cron => ShouldRunByCron(manifest, now, config, logger), @@ -349,4 +354,29 @@ DateTime now var nextScheduledTime = lastSuccessfulRun.Value.AddSeconds(intervalSeconds); return nextScheduledTime <= now; } + + /// + /// Checks whether the current time falls within any of the manifest's exclusion windows. + /// + private static bool IsExcluded(Manifest manifest, DateTime now, ILogger logger) + { + var exclusions = manifest.GetExclusions(); + if (exclusions.Count == 0) + return false; + + foreach (var exclusion in exclusions) + { + if (exclusion.IsExcluded(now)) + { + logger.LogDebug( + "Manifest {ManifestId} is excluded by {ExclusionType} exclusion, skipping", + manifest.Id, + exclusion.Type + ); + return true; + } + } + + return false; + } } diff --git a/tests/Trax.Scheduler.Tests.Integration/UnitTests/ExclusionScheduleTests.cs b/tests/Trax.Scheduler.Tests.Integration/UnitTests/ExclusionScheduleTests.cs new file mode 100644 index 0000000..a839a3b --- /dev/null +++ b/tests/Trax.Scheduler.Tests.Integration/UnitTests/ExclusionScheduleTests.cs @@ -0,0 +1,173 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Trax.Effect.Enums; +using Trax.Effect.Models.Manifest; +using Trax.Scheduler.Configuration; +using Trax.Scheduler.Trains.ManifestManager.Utilities; + +namespace Trax.Scheduler.Tests.Integration.UnitTests; + +[TestFixture] +public class ExclusionScheduleTests +{ + private SchedulerConfiguration _config = null!; + private ILogger _logger = null!; + + [SetUp] + public void SetUp() + { + _config = new SchedulerConfiguration(); + _logger = NullLoggerFactory.Instance.CreateLogger("test"); + } + + // ── Cron + DaysOfWeek ─────────────────────────────────────────── + + [Test] + public void ShouldRunNow_WhenCronManifestExcludedByDayOfWeek_ReturnsFalse() + { + var saturday = new DateTime(2026, 3, 7, 3, 0, 0, DateTimeKind.Utc); + var manifest = CreateCronManifest(); + manifest.SetExclusions([Exclude.DaysOfWeek(DayOfWeek.Saturday, DayOfWeek.Sunday)]); + + var result = SchedulingHelpers.ShouldRunNow(manifest, saturday, _config, _logger); + + result.Should().BeFalse("Saturday is excluded"); + } + + [Test] + public void ShouldRunNow_WhenCronManifestNotExcluded_ReturnsTrue() + { + var monday = new DateTime(2026, 3, 2, 3, 0, 0, DateTimeKind.Utc); + var manifest = CreateCronManifest(); + manifest.SetExclusions([Exclude.DaysOfWeek(DayOfWeek.Saturday, DayOfWeek.Sunday)]); + + var result = SchedulingHelpers.ShouldRunNow(manifest, monday, _config, _logger); + + result.Should().BeTrue("Monday is not excluded and manifest has never run"); + } + + // ── Interval + Dates ──────────────────────────────────────────── + + [Test] + public void ShouldRunNow_WhenIntervalManifestExcludedByDate_ReturnsFalse() + { + var christmas = new DateTime(2026, 12, 25, 12, 0, 0, DateTimeKind.Utc); + var manifest = CreateIntervalManifest(intervalSeconds: 60); + manifest.SetExclusions([Exclude.Dates(new DateOnly(2026, 12, 25))]); + + var result = SchedulingHelpers.ShouldRunNow(manifest, christmas, _config, _logger); + + result.Should().BeFalse("December 25 is excluded"); + } + + // ── TimeWindow ────────────────────────────────────────────────── + + [Test] + public void ShouldRunNow_WhenManifestExcludedByTimeWindow_ReturnsFalse() + { + var maintenanceTime = new DateTime(2026, 3, 2, 3, 0, 0, DateTimeKind.Utc); + var manifest = CreateCronManifest(); + manifest.SetExclusions([ + Exclude.TimeWindow(TimeOnly.Parse("02:00"), TimeOnly.Parse("04:00")), + ]); + + var result = SchedulingHelpers.ShouldRunNow(manifest, maintenanceTime, _config, _logger); + + result.Should().BeFalse("3:00 AM is within the 2:00-4:00 exclusion window"); + } + + // ── Multiple Exclusions ───────────────────────────────────────── + + [Test] + public void ShouldRunNow_WhenAnyExclusionMatches_ReturnsFalse() + { + var saturday = new DateTime(2026, 3, 7, 12, 0, 0, DateTimeKind.Utc); + var manifest = CreateCronManifest(); + manifest.SetExclusions([ + Exclude.Dates(new DateOnly(2026, 12, 25)), + Exclude.DaysOfWeek(DayOfWeek.Saturday, DayOfWeek.Sunday), + ]); + + var result = SchedulingHelpers.ShouldRunNow(manifest, saturday, _config, _logger); + + result + .Should() + .BeFalse("Saturday matches the DaysOfWeek exclusion even though Dates doesn't match"); + } + + [Test] + public void ShouldRunNow_WhenNoExclusionMatches_ReturnsTrue() + { + var monday = new DateTime(2026, 3, 2, 12, 0, 0, DateTimeKind.Utc); + var manifest = CreateCronManifest(); + manifest.SetExclusions([ + Exclude.Dates(new DateOnly(2026, 12, 25)), + Exclude.DaysOfWeek(DayOfWeek.Saturday, DayOfWeek.Sunday), + Exclude.TimeWindow(TimeOnly.Parse("02:00"), TimeOnly.Parse("04:00")), + ]); + + var result = SchedulingHelpers.ShouldRunNow(manifest, monday, _config, _logger); + + result + .Should() + .BeTrue("Monday at noon doesn't match any exclusion and manifest has never run"); + } + + // ── Empty Exclusions ──────────────────────────────────────────── + + [Test] + public void ShouldRunNow_WithEmptyExclusions_BehavesNormally() + { + var now = new DateTime(2026, 3, 2, 12, 0, 0, DateTimeKind.Utc); + var manifest = CreateCronManifest(); + manifest.SetExclusions([]); + + var result = SchedulingHelpers.ShouldRunNow(manifest, now, _config, _logger); + + result.Should().BeTrue("no exclusions should not block scheduling"); + } + + // ── Once + Exclusion ──────────────────────────────────────────── + + [Test] + public void ShouldRunNow_WhenOnceManifestExcluded_ReturnsFalse() + { + var saturday = new DateTime(2026, 3, 7, 12, 0, 0, DateTimeKind.Utc); + var manifest = new Manifest + { + ExternalId = Guid.NewGuid().ToString("N"), + Name = "TestTrain", + ScheduleType = ScheduleType.Once, + ScheduledAt = DateTime.UtcNow.AddMinutes(-5), + IsEnabled = true, + }; + manifest.SetExclusions([Exclude.DaysOfWeek(DayOfWeek.Saturday)]); + + var result = SchedulingHelpers.ShouldRunNow(manifest, saturday, _config, _logger); + + result.Should().BeFalse("Saturday is excluded even for Once manifests"); + } + + // ── Helpers ───────────────────────────────────────────────────── + + private static Manifest CreateCronManifest() => + new() + { + ExternalId = Guid.NewGuid().ToString("N"), + Name = "TestTrain", + ScheduleType = ScheduleType.Cron, + CronExpression = "0 3 * * *", + IsEnabled = true, + }; + + private static Manifest CreateIntervalManifest(int intervalSeconds) => + new() + { + ExternalId = Guid.NewGuid().ToString("N"), + Name = "TestTrain", + ScheduleType = ScheduleType.Interval, + IntervalSeconds = intervalSeconds, + IsEnabled = true, + }; +} diff --git a/tests/Trax.Scheduler.Tests.Integration/UnitTests/ExclusionTests.cs b/tests/Trax.Scheduler.Tests.Integration/UnitTests/ExclusionTests.cs new file mode 100644 index 0000000..3c0e171 --- /dev/null +++ b/tests/Trax.Scheduler.Tests.Integration/UnitTests/ExclusionTests.cs @@ -0,0 +1,196 @@ +using FluentAssertions; +using Trax.Effect.Models.Manifest; + +namespace Trax.Scheduler.Tests.Integration.UnitTests; + +[TestFixture] +public class ExclusionTests +{ + // ── DaysOfWeek ────────────────────────────────────────────────── + + [Test] + public void IsExcluded_WhenDayOfWeekMatches_ReturnsTrue() + { + var exclusion = Exclude.DaysOfWeek(DayOfWeek.Saturday); + var saturday = new DateTime(2026, 3, 7, 12, 0, 0, DateTimeKind.Utc); + + exclusion.IsExcluded(saturday).Should().BeTrue(); + } + + [Test] + public void IsExcluded_WhenDayOfWeekDoesNotMatch_ReturnsFalse() + { + var exclusion = Exclude.DaysOfWeek(DayOfWeek.Saturday, DayOfWeek.Sunday); + var monday = new DateTime(2026, 3, 2, 12, 0, 0, DateTimeKind.Utc); + + exclusion.IsExcluded(monday).Should().BeFalse(); + } + + // ── Dates ─────────────────────────────────────────────────────── + + [Test] + public void IsExcluded_WhenDateMatches_ReturnsTrue() + { + var exclusion = Exclude.Dates(new DateOnly(2026, 12, 25)); + var christmas = new DateTime(2026, 12, 25, 15, 30, 0, DateTimeKind.Utc); + + exclusion.IsExcluded(christmas).Should().BeTrue(); + } + + [Test] + public void IsExcluded_WhenDateDoesNotMatch_ReturnsFalse() + { + var exclusion = Exclude.Dates(new DateOnly(2026, 12, 25)); + var boxingDay = new DateTime(2026, 12, 26, 10, 0, 0, DateTimeKind.Utc); + + exclusion.IsExcluded(boxingDay).Should().BeFalse(); + } + + // ── DateRange ─────────────────────────────────────────────────── + + [Test] + public void IsExcluded_WhenWithinDateRange_ReturnsTrue() + { + var exclusion = Exclude.DateRange(new DateOnly(2026, 12, 23), new DateOnly(2027, 1, 2)); + var christmas = new DateTime(2026, 12, 25, 12, 0, 0, DateTimeKind.Utc); + + exclusion.IsExcluded(christmas).Should().BeTrue(); + } + + [Test] + public void IsExcluded_WhenOutsideDateRange_ReturnsFalse() + { + var exclusion = Exclude.DateRange(new DateOnly(2026, 12, 23), new DateOnly(2027, 1, 2)); + var beforeRange = new DateTime(2026, 12, 22, 23, 59, 0, DateTimeKind.Utc); + + exclusion.IsExcluded(beforeRange).Should().BeFalse(); + } + + [Test] + public void IsExcluded_WhenOnDateRangeStartBoundary_ReturnsTrue() + { + var exclusion = Exclude.DateRange(new DateOnly(2026, 12, 23), new DateOnly(2027, 1, 2)); + var startDay = new DateTime(2026, 12, 23, 0, 0, 0, DateTimeKind.Utc); + + exclusion.IsExcluded(startDay).Should().BeTrue(); + } + + [Test] + public void IsExcluded_WhenOnDateRangeEndBoundary_ReturnsTrue() + { + var exclusion = Exclude.DateRange(new DateOnly(2026, 12, 23), new DateOnly(2027, 1, 2)); + var endDay = new DateTime(2027, 1, 2, 23, 59, 0, DateTimeKind.Utc); + + exclusion.IsExcluded(endDay).Should().BeTrue(); + } + + // ── TimeWindow ────────────────────────────────────────────────── + + [Test] + public void IsExcluded_WhenWithinTimeWindow_ReturnsTrue() + { + var exclusion = Exclude.TimeWindow(TimeOnly.Parse("02:00"), TimeOnly.Parse("04:00")); + var insideWindow = new DateTime(2026, 3, 2, 3, 0, 0, DateTimeKind.Utc); + + exclusion.IsExcluded(insideWindow).Should().BeTrue(); + } + + [Test] + public void IsExcluded_WhenOutsideTimeWindow_ReturnsFalse() + { + var exclusion = Exclude.TimeWindow(TimeOnly.Parse("02:00"), TimeOnly.Parse("04:00")); + var outsideWindow = new DateTime(2026, 3, 2, 12, 0, 0, DateTimeKind.Utc); + + exclusion.IsExcluded(outsideWindow).Should().BeFalse(); + } + + [Test] + public void IsExcluded_WhenTimeWindowCrossesMidnight_InsideBeforeMidnight_ReturnsTrue() + { + var exclusion = Exclude.TimeWindow(TimeOnly.Parse("23:00"), TimeOnly.Parse("02:00")); + var beforeMidnight = new DateTime(2026, 3, 2, 23, 30, 0, DateTimeKind.Utc); + + exclusion.IsExcluded(beforeMidnight).Should().BeTrue(); + } + + [Test] + public void IsExcluded_WhenTimeWindowCrossesMidnight_InsideAfterMidnight_ReturnsTrue() + { + var exclusion = Exclude.TimeWindow(TimeOnly.Parse("23:00"), TimeOnly.Parse("02:00")); + var afterMidnight = new DateTime(2026, 3, 3, 1, 0, 0, DateTimeKind.Utc); + + exclusion.IsExcluded(afterMidnight).Should().BeTrue(); + } + + [Test] + public void IsExcluded_WhenTimeWindowCrossesMidnight_OutsideWindow_ReturnsFalse() + { + var exclusion = Exclude.TimeWindow(TimeOnly.Parse("23:00"), TimeOnly.Parse("02:00")); + var outsideWindow = new DateTime(2026, 3, 2, 12, 0, 0, DateTimeKind.Utc); + + exclusion.IsExcluded(outsideWindow).Should().BeFalse(); + } + + // ── JSON Serialization Round-Trip ─────────────────────────────── + + [Test] + public void Exclusions_SerializeAndDeserialize_RoundTrip() + { + var manifest = new Manifest { ExternalId = Guid.NewGuid().ToString("N"), Name = "Test" }; + var exclusions = new List + { + Exclude.DaysOfWeek(DayOfWeek.Saturday, DayOfWeek.Sunday), + Exclude.Dates(new DateOnly(2026, 12, 25)), + Exclude.DateRange(new DateOnly(2026, 12, 23), new DateOnly(2027, 1, 2)), + Exclude.TimeWindow(TimeOnly.Parse("02:00"), TimeOnly.Parse("04:00")), + }; + + manifest.SetExclusions(exclusions); + var deserialized = manifest.GetExclusions(); + + deserialized.Should().HaveCount(4); + + deserialized[0].Type.Should().Be(ExclusionType.DaysOfWeek); + deserialized[0].DaysOfWeek.Should().Contain(DayOfWeek.Saturday); + deserialized[0].DaysOfWeek.Should().Contain(DayOfWeek.Sunday); + + deserialized[1].Type.Should().Be(ExclusionType.Dates); + deserialized[1].Dates.Should().Contain(new DateOnly(2026, 12, 25)); + + deserialized[2].Type.Should().Be(ExclusionType.DateRange); + deserialized[2].StartDate.Should().Be(new DateOnly(2026, 12, 23)); + deserialized[2].EndDate.Should().Be(new DateOnly(2027, 1, 2)); + + deserialized[3].Type.Should().Be(ExclusionType.TimeWindow); + deserialized[3].StartTime.Should().Be(TimeOnly.Parse("02:00")); + deserialized[3].EndTime.Should().Be(TimeOnly.Parse("04:00")); + } + + [Test] + public void GetExclusions_WhenNull_ReturnsEmptyList() + { + var manifest = new Manifest + { + ExternalId = Guid.NewGuid().ToString("N"), + Name = "Test", + Exclusions = null, + }; + + manifest.GetExclusions().Should().BeEmpty(); + } + + [Test] + public void SetExclusions_WhenEmptyList_SetsNull() + { + var manifest = new Manifest + { + ExternalId = Guid.NewGuid().ToString("N"), + Name = "Test", + Exclusions = "some-previous-value", + }; + + manifest.SetExclusions([]); + + manifest.Exclusions.Should().BeNull(); + } +}