diff --git a/api-reference/scheduler-api/schedule.md b/api-reference/scheduler-api/schedule.md index 029250a..24a2a38 100644 --- a/api-reference/scheduler-api/schedule.md +++ b/api-reference/scheduler-api/schedule.md @@ -90,6 +90,7 @@ The `ScheduleOptions` fluent builder consolidates all optional scheduling parame | `.Timeout(TimeSpan)` | Job execution timeout. `null` uses global default. | | `.OnMisfire(MisfirePolicy)` | Misfire policy for missed runs. `FireOnceNow` (default) fires immediately; `DoNothing` skips and waits for the next natural occurrence. Only applies to Cron and Interval types. | | `.MisfireThreshold(TimeSpan)` | Grace period before the misfire policy takes effect. Overrides the global `DefaultMisfireThreshold`. | +| `.Exclude(Exclusion)` | Adds an exclusion window. The manifest is skipped when any exclusion matches the current time. Multiple can be combined. Use `Exclude.DaysOfWeek(...)`, `Exclude.Dates(...)`, `Exclude.DateRange(...)`, or `Exclude.TimeWindow(...)` factories. See [Exclusion Windows]({{ site.baseurl }}{% link scheduler/exclusions.md %}). | ### Group-level methods diff --git a/api-reference/scheduler-api/scheduling-helpers.md b/api-reference/scheduler-api/scheduling-helpers.md index 38feb48..630464d 100644 --- a/api-reference/scheduler-api/scheduling-helpers.md +++ b/api-reference/scheduler-api/scheduling-helpers.md @@ -136,6 +136,19 @@ Determines behavior when a scheduled run is missed. See [Misfire Policies]({{ site.baseurl }}{% link scheduler/scheduling-options.md %}#misfire-policies) for detailed behavior and examples. +### ExclusionType Enum + +Defines the kind of exclusion window for a manifest schedule. Used inside the JSONB `exclusions` column. + +| Value | Description | +|-------|-------------| +| `DaysOfWeek` | Exclude specific days of the week (e.g., weekends) | +| `Dates` | Exclude specific dates (e.g., holidays) | +| `DateRange` | Exclude a contiguous date range (start–end inclusive) | +| `TimeWindow` | Exclude a daily time window (supports midnight crossover) | + +See [Exclusion Windows]({{ site.baseurl }}{% link scheduler/exclusions.md %}) for usage patterns and examples. + --- ## ManifestOptions @@ -154,6 +167,7 @@ public class ManifestOptions | `Priority` | `int` | `0` | Manifest-level priority stored on the manifest record. Note: dispatch ordering is primarily determined by **ManifestGroup.Priority** (set from the dashboard). This manifest-level priority is used as the work queue entry's priority when the manifest is queued. For dependent manifests, `DependentPriorityBoost` (default 16) is added on top at dispatch time. Can also be set via the `priority` parameter on scheduling methods. | | `MisfirePolicy` | `MisfirePolicy?` | `null` | Per-manifest misfire policy override. `null` uses the global `DefaultMisfirePolicy`. Only applies to Cron and Interval schedule types. See [Misfire Policies]({{ site.baseurl }}{% link scheduler/scheduling-options.md %}#misfire-policies). | | `MisfireThreshold` | `TimeSpan?` | `null` | Per-manifest misfire threshold override. `null` uses the global `DefaultMisfireThreshold` (60 seconds). | +| `Exclusions` | `List` | `[]` | Exclusion windows for this manifest. When any exclusion matches the current time, the manifest is skipped. Excluded periods are "intentionally skipped", not misfires. See [Exclusion Windows]({{ site.baseurl }}{% link scheduler/exclusions.md %}). | ### Example diff --git a/comparison.md b/comparison.md index f0026fe..93e18fb 100644 --- a/comparison.md +++ b/comparison.md @@ -22,14 +22,14 @@ All three run background work in .NET. They solve different problems. |---|---|---|---| | Cron expressions | 5-field (minute granularity) | 6-7 field (second granularity) | 5-field via `Cron.*` helpers | | Simple intervals | `Every.Minutes(5)` | `SimpleTrigger` with repeat count | `TimeSpan` delay | -| Calendar exclusions | No | Yes — holidays, weekends, custom calendars | No | -| Daily time windows | No | Yes — "9am-5pm Mon-Fri every hour" | No | +| Calendar exclusions | Yes — `Exclude.DaysOfWeek()`, `Exclude.Dates()`, `Exclude.DateRange()` | Yes — holidays, weekends, custom calendars | No | +| Daily time windows | Yes — `Exclude.TimeWindow()` (supports midnight crossover) | Yes — "9am-5pm Mon-Fri every hour" | No | | DST-aware intervals | No | Yes — `PreserveHourOfDayAcrossDaylightSavings` | No | | Fire-and-forget | `TriggerAsync(externalId)` | `scheduler.TriggerJob(key)` | `BackgroundJob.Enqueue(() => ...)` | | Delayed one-off | `ScheduleOnceAsync(input, delay)` or `TriggerAsync(id, delay)` | Trigger with future start time | `BackgroundJob.Schedule(delay)` | | Misfire policies | Implicit (run if overdue) | 6+ explicit policies per trigger type | N/A (queue-based) | -Quartz.NET has the richest time-based scheduling. If you need "every 30 seconds between 9am and 5pm on weekdays, excluding holidays," Quartz is the only option. Trax covers the common cases — cron and intervals — but doesn't attempt calendar-aware scheduling. +Quartz.NET has the richest time-based scheduling with second-granularity cron and DST-aware triggers. Trax now supports calendar exclusions (days of week, specific dates, date ranges, and daily time windows) alongside cron and interval schedules. For advanced scenarios like "every 30 seconds between 9am and 5pm," Quartz's sub-minute cron is still required. ### Job Composition @@ -124,7 +124,7 @@ Hangfire wins on ceremony. You can go from zero to a running background job in f **You need a simple background job processor.** If the job is "send this email in 5 minutes" or "resize this image asynchronously," Hangfire does it in one line. Trax now supports delayed one-off jobs via `ScheduleOnceAsync`, but the train/step/manifest model still adds ceremony compared to Hangfire's lambda-based approach for simple fire-and-forget work with no internal structure. -**You need precise time-based scheduling.** If the requirement is "every 15 seconds," "only on business days excluding holidays," or "between 9am and 5pm with DST handling," Quartz.NET has scheduling primitives that Trax doesn't. Trax covers cron and intervals — the common cases — but not calendar-aware or sub-minute cron scheduling. +**You need sub-minute or DST-aware scheduling.** Trax now supports calendar exclusions (weekends, holidays, date ranges, time windows), but if you need second-granularity cron ("every 15 seconds") or DST-aware interval handling, Quartz.NET has scheduling primitives that Trax doesn't. **You're not on PostgreSQL.** Trax's distributed coordination relies on PostgreSQL advisory locks and `FOR UPDATE SKIP LOCKED`. There is no SQL Server, MySQL, or Redis backend. If your infrastructure is on a different database, Quartz.NET or Hangfire will work; Trax won't. diff --git a/scheduler/exclusions.md b/scheduler/exclusions.md new file mode 100644 index 0000000..ad86b53 --- /dev/null +++ b/scheduler/exclusions.md @@ -0,0 +1,106 @@ +--- +layout: default +title: Exclusion Windows +parent: Scheduling +nav_order: 8 +--- + +# Exclusion Windows + +Exclusion windows let you skip execution during specific periods — weekends, holidays, maintenance windows, or daily time ranges. When any exclusion matches the current time, the manifest is skipped. Excluded periods are treated as **intentionally skipped**, not as misfires. + +## Exclusion Types + +### DaysOfWeek + +Skip specific days of the week: + +```csharp +scheduler.Schedule( + "daily-report", + new ReportInput(), + Cron.Daily(hour: 3), + o => o.Exclude(Exclude.DaysOfWeek(DayOfWeek.Saturday, DayOfWeek.Sunday))); +``` + +### Dates + +Skip specific dates (e.g., holidays): + +```csharp +var holidays = new[] +{ + new DateOnly(2026, 12, 25), // Christmas + new DateOnly(2027, 1, 1), // New Year's Day +}; + +scheduler.Schedule( + "daily-report", + new ReportInput(), + Cron.Daily(hour: 3), + o => o.Exclude(Exclude.Dates(holidays))); +``` + +### DateRange + +Skip a contiguous date range (start and end inclusive): + +```csharp +scheduler.Schedule( + "daily-report", + new ReportInput(), + Cron.Daily(hour: 3), + o => o.Exclude(Exclude.DateRange( + new DateOnly(2026, 12, 23), + new DateOnly(2027, 1, 2)))); +``` + +### TimeWindow + +Skip a daily time window. Supports midnight crossover (e.g., 23:00-02:00): + +```csharp +scheduler.Schedule( + "sync-hourly", + new SyncInput(), + Cron.Hourly(), + o => o.Exclude(Exclude.TimeWindow( + TimeOnly.Parse("02:00"), + TimeOnly.Parse("04:00")))); +``` + +## Combining Exclusions + +Multiple exclusions can be combined. If **any** exclusion matches, the manifest is skipped: + +```csharp +scheduler.Schedule( + "weekday-report", + new ReportInput(), + Cron.Daily(hour: 8), + o => o + .Exclude(Exclude.DaysOfWeek(DayOfWeek.Saturday, DayOfWeek.Sunday)) + .Exclude(Exclude.Dates(new DateOnly(2026, 12, 25))) + .Exclude(Exclude.TimeWindow(TimeOnly.Parse("02:00"), TimeOnly.Parse("04:00")))); +``` + +## Interaction with Misfire Policies + +Excluded periods are **intentionally skipped** — they are not considered misfires. When the excluded period ends, normal scheduling resumes. If the manifest is overdue at that point, the existing misfire policy determines what happens: + +- **`FireOnceNow`** (default): the manifest fires immediately to catch up +- **`DoNothing`**: the scheduler checks the most recent interval boundary and fires only if within the misfire threshold + +This interaction is usually correct: +- A daily report excluded on weekends with `FireOnceNow` fires once on Monday morning to catch up +- The same report with `DoNothing` waits for the next natural 3am occurrence + +## Storage + +Exclusions are stored as a JSONB column on the manifest table. They are configured per-manifest via the `ScheduleOptions` fluent builder — there are no global exclusion defaults. Exclusions apply to all schedule types (Cron, Interval, Once). Dependent manifests are triggered by parent completion rather than time, so exclusions on dependent manifests are not evaluated during normal scheduling. + +## Dashboard + +The ManifestDetailPage displays configured exclusion windows in an "Exclusion Windows" card when present, showing the type and formatted description of each exclusion. + +*API Reference: [ScheduleOptions — Exclude()]({{ site.baseurl }}{% link api-reference/scheduler-api/schedule.md %}#scheduleoptions)* diff --git a/scheduler/scheduling-options.md b/scheduler/scheduling-options.md index aaa83cd..be2d0ab 100644 --- a/scheduler/scheduling-options.md +++ b/scheduler/scheduling-options.md @@ -200,6 +200,29 @@ See [Dormant Dependents](dependent-trains.md#dormant-dependents) for full detail See [Dependent Trains](dependent-trains.md) for details on chaining trains, and [Delayed / One-Off Jobs](delayed-jobs.md) for one-off scheduling. +## Exclusion Windows + +Exclusion windows skip execution during specific periods — weekends, holidays, maintenance windows, or daily time ranges. Add exclusions via the `.Exclude()` method on `ScheduleOptions`: + +```csharp +scheduler.Schedule( + "daily-report", + new ReportInput(), + Cron.Daily(hour: 3), + o => o + .Exclude(Exclude.DaysOfWeek(DayOfWeek.Saturday, DayOfWeek.Sunday)) + .Exclude(Exclude.Dates(new DateOnly(2026, 12, 25))) + .Exclude(Exclude.TimeWindow(TimeOnly.Parse("02:00"), TimeOnly.Parse("04:00")))); +``` + +Multiple exclusions can be combined — if ANY matches, the manifest is skipped. Excluded periods are "intentionally skipped", not misfires. When the excluded period ends, normal scheduling resumes and the misfire policy determines catch-up behavior. + +Four built-in exclusion types: `DaysOfWeek`, `Dates`, `DateRange`, `TimeWindow` (supports midnight crossover). + +See [Exclusion Windows](exclusions.md) for full details, examples, and misfire interaction. + +*API Reference: [ScheduleOptions — Exclude()]({{ site.baseurl }}{% link api-reference/scheduler-api/schedule.md %}#scheduleoptions)* + ## Misfire Policies A **misfire** occurs when a scheduled job was supposed to fire but couldn't — the scheduler was down, or the job was blocked by an active execution or dead letter. When the scheduler recovers, the misfire policy determines what happens.