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();
+ }
+}