Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/Trax.Scheduler/Configuration/ManifestOptions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Trax.Effect.Enums;
using Trax.Effect.Models.Manifest;

namespace Trax.Scheduler.Configuration;

Expand Down Expand Up @@ -87,4 +88,14 @@ public class ManifestOptions
/// Null means use the global default from SchedulerConfiguration.DefaultMisfireThreshold.
/// </summary>
public TimeSpan? MisfireThreshold { get; set; }

/// <summary>
/// Gets or sets the exclusion windows for this manifest.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public List<Exclusion> Exclusions { get; set; } = [];
}
18 changes: 18 additions & 0 deletions src/Trax.Scheduler/Configuration/ScheduleOptions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Trax.Effect.Enums;
using Trax.Effect.Models.Manifest;

namespace Trax.Scheduler.Configuration;

Expand Down Expand Up @@ -31,6 +32,7 @@ public class ScheduleOptions
internal bool _isDormant;
internal MisfirePolicy? _misfirePolicy;
internal TimeSpan? _misfireThreshold;
internal List<Exclusion> _exclusions = [];

// Group-level state
internal string? _groupId;
Expand Down Expand Up @@ -121,6 +123,21 @@ public ScheduleOptions MisfireThreshold(TimeSpan threshold)
return this;
}

/// <summary>
/// Adds an exclusion window to this manifest. The manifest will not be scheduled
/// during any period matched by the exclusion.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public ScheduleOptions Exclude(Exclusion exclusion)
{
_exclusions.Add(exclusion);
return this;
}

// ── Group-level fluent methods ────────────────────────────────────

/// <summary>
Expand Down Expand Up @@ -177,5 +194,6 @@ internal ManifestOptions ToManifestOptions() =>
IsDormant = _isDormant,
MisfirePolicy = _misfirePolicy,
MisfireThreshold = _misfireThreshold,
Exclusions = _exclusions,
};
}
14 changes: 14 additions & 0 deletions src/Trax.Scheduler/Extensions/DataContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ internal static async Task<Manifest> UpsertManifestAsync(
existing.Priority = options.Priority;
ApplySchedule(existing, schedule);
ApplyMisfireOptions(existing, options);
ApplyExclusions(existing, options);

return existing;
}
Expand All @@ -151,6 +152,7 @@ internal static async Task<Manifest> UpsertManifestAsync(
manifest.SetProperties(input);
ApplySchedule(manifest, schedule);
ApplyMisfireOptions(manifest, options);
ApplyExclusions(manifest, options);

context.Manifests.Add(manifest);

Expand Down Expand Up @@ -237,6 +239,7 @@ internal static async Task<Manifest> UpsertDependentManifestAsync(
existing.CronExpression = null;
existing.IntervalSeconds = null;
ApplyMisfireOptions(existing, options);
ApplyExclusions(existing, options);

return existing;
}
Expand All @@ -257,6 +260,7 @@ internal static async Task<Manifest> UpsertDependentManifestAsync(
};
manifest.SetProperties(input);
ApplyMisfireOptions(manifest, options);
ApplyExclusions(manifest, options);

context.Manifests.Add(manifest);

Expand Down Expand Up @@ -339,6 +343,7 @@ internal static async Task<Manifest> UpsertOnceManifestAsync(
existing.CronExpression = null;
existing.IntervalSeconds = null;
ApplyMisfireOptions(existing, options);
ApplyExclusions(existing, options);

return existing;
}
Expand All @@ -359,6 +364,7 @@ internal static async Task<Manifest> UpsertOnceManifestAsync(
};
manifest.SetProperties(input);
ApplyMisfireOptions(manifest, options);
ApplyExclusions(manifest, options);

context.Manifests.Add(manifest);

Expand Down Expand Up @@ -389,4 +395,12 @@ private static void ApplyMisfireOptions(Manifest manifest, ManifestOptions optio
? (int)options.MisfireThreshold.Value.TotalSeconds
: null;
}

/// <summary>
/// Applies exclusion window configuration to a manifest from the options.
/// </summary>
private static void ApplyExclusions(Manifest manifest, ManifestOptions options)
{
manifest.SetExclusions(options.Exclusions);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Manifest> GetManifestByExternalIdAsync(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -349,4 +354,29 @@ DateTime now
var nextScheduledTime = lastSuccessfulRun.Value.AddSeconds(intervalSeconds);
return nextScheduledTime <= now;
}

/// <summary>
/// Checks whether the current time falls within any of the manifest's exclusion windows.
/// </summary>
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;
}
}
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading
Loading