Skip to content

Add RFC 5545 RRULE recurrence trigger support#2990

Merged
lahma merged 25 commits into
3.xfrom
recurrence
Apr 3, 2026
Merged

Add RFC 5545 RRULE recurrence trigger support#2990
lahma merged 25 commits into
3.xfrom
recurrence

Conversation

@lahma

@lahma lahma commented Apr 3, 2026

Copy link
Copy Markdown
Member

Summary

  • Adds a new RecurrenceTrigger that fires based on iCalendar RFC 5545 recurrence rules (RRULE), enabling complex scheduling patterns that cannot be expressed with CRON expressions
  • Custom lightweight RRULE engine (~1.5K LOC) with lazy GetNextOccurrence() evaluation — no new external dependencies
  • Full RFC 5545 RRULE support: all frequencies (YEARLY through SECONDLY), all BY* rules (BYDAY, BYMONTHDAY, BYMONTH, BYSETPOS, etc.), COUNT, UNTIL, INTERVAL, WKST
  • No breaking changes, no database schema changes (uses existing SIMPROP_TRIGGERS table)

Examples of patterns now possible

RRULE Pattern
FREQ=MONTHLY;BYDAY=2MO Every 2nd Monday of the month
FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR Every other week on Mon/Wed/Fri
FREQ=YEARLY;BYMONTH=3;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1 Last weekday of March each year
FREQ=MONTHLY;BYMONTHDAY=-1 Last day of every month
FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR Every weekday

Usage

ITrigger trigger = TriggerBuilder.Create()
    .WithIdentity("myTrigger")
    .WithRecurrenceSchedule("FREQ=MONTHLY;BYDAY=2MO", b => b
        .InTimeZone(TimeZoneInfo.FindSystemTimeZoneById("America/New_York")))
    .StartNow()
    .Build();

Components added

Component Description
IRecurrenceTrigger Public interface
RecurrenceTriggerImpl Sealed trigger implementation
RecurrenceScheduleBuilder Fluent builder + WithRecurrenceSchedule() extensions
RecurrenceRule (internal) RRULE parser + lazy fire-time calculator
ByRuleExpander (internal) RFC 5545 expand/limit table
Persistence delegate Uses SIMPROP_TRIGGERS (String1=RRULE, Int1=TimesTriggered, TimeZoneId)
JSON serializers Both System.Text.Json and Newtonsoft.Json
DI extension WithRecurrenceSchedule() on ITriggerConfigurator
Documentation Tutorial page with examples

Background

Closes #1259. Supersedes #1276 (which embedded the EWSoftware PDI library at 38 files / 12K LOC — we replaced that with a focused ~1.5K LOC internal engine).

Test plan

  • 55 new unit tests covering RRULE parsing, round-trip serialization, all frequency types, BY* rule combinations, COUNT/UNTIL bounds, trigger lifecycle, misfire handling, builder API
  • All 1168 existing unit tests pass (0 failures, 4 pre-existing skips)
  • Full solution builds with zero warnings across all target frameworks (net462, net472, net8.0, net9.0, net10.0, netstandard2.0)
  • Integration test with AdoJobStore persistence round-trip
  • Manual verification of DST transition edge cases

🤖 Generated with Claude Code

@lahma lahma added the port-main Requires porting to main branch label Apr 3, 2026
@lahma
lahma requested a review from Copilot April 3, 2026 10:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces first-class RFC 5545 RRULE-based scheduling to Quartz.NET by adding a new RecurrenceTrigger type, including an internal RRULE parser/evaluator, builders/extensions, persistence via SIMPROP_TRIGGERS, JSON serialization support, DI integration, and tutorial documentation.

Changes:

  • Added IRecurrenceTrigger / RecurrenceTriggerImpl plus RecurrenceScheduleBuilder and WithRecurrenceSchedule(...) extensions.
  • Implemented an internal RRULE engine (RecurrenceRule, ByRuleExpander, RecurrenceFrequency) to compute next occurrences lazily.
  • Wired up persistence (ADO.NET), JSON serializers (STJ + Newtonsoft), DI extensions, and docs/tests.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/Quartz/RecurrenceScheduleBuilder.cs New schedule builder + TriggerBuilder extensions for RRULE schedules
src/Quartz/MisfireInstruction.cs Adds misfire instruction constants for RecurrenceTrigger
src/Quartz/IRecurrenceTrigger.cs New public trigger interface for RRULE-based triggers
src/Quartz/Impl/Triggers/RecurrenceTriggerImpl.cs New trigger implementation integrating RRULE evaluation, misfire, calendars
src/Quartz/Impl/Recurrence/RecurrenceRule.cs RRULE parsing + next occurrence evaluation logic
src/Quartz/Impl/Recurrence/RecurrenceFrequency.cs Enum for RRULE frequency semantics/order
src/Quartz/Impl/Recurrence/ByRuleExpander.cs BY* expansion/limiting and BYSETPOS selection logic
src/Quartz/Impl/AdoJobStore/StdAdoDelegate.cs Registers the new trigger persistence delegate
src/Quartz/Impl/AdoJobStore/RecurrenceTriggerPersistenceDelegate.cs Persists RRULE trigger state via SIMPROP_TRIGGERS
src/Quartz/Impl/AdoJobStore/AdoConstants.cs Adds ADO trigger type discriminator RECUR
src/Quartz.Tests.Unit/Impl/Triggers/RecurrenceTriggerImplTest.cs Unit tests for trigger behavior + persistence delegate round-trip
src/Quartz.Tests.Unit/Impl/Recurrence/RecurrenceRuleTest.cs Unit tests for RRULE parsing and evaluation (incl. DST scenarios)
src/Quartz.Serialization.SystemTextJson/Triggers/RecurrenceTriggerSerializer.cs Adds STJ trigger serializer for recurrence triggers
src/Quartz.Serialization.SystemTextJson/Converters/TriggerConverter.cs Registers STJ serializer for RecurrenceTriggerImpl
src/Quartz.Serialization.Json/Triggers/RecurrenceTriggerSerializer.cs Adds Newtonsoft trigger serializer for recurrence triggers
src/Quartz.Serialization.Json/Converters/TriggerConverter.cs Registers Newtonsoft serializer for RecurrenceTriggerImpl
src/Quartz.Extensions.DependencyInjection/TriggerExtensions.cs Adds DI-friendly WithRecurrenceSchedule(...) extension
docs/documentation/quartz-3.x/tutorial/recurrencetrigger.md New tutorial page documenting RRULE trigger usage
docs/documentation/quartz-3.x/tutorial/README.md Links the new tutorial page from the tutorial index

Comment on lines +390 to +395
// Validate that the RRULE string is parseable
try
{
Recurrence.RecurrenceRule.Parse(recurrenceRuleString);
}
catch (FormatException ex)

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recurrence.RecurrenceRule.Parse(...) does not resolve with the current using Quartz.Impl.Recurrence; and will not compile (there is no Recurrence namespace in scope here). Use RecurrenceRule.Parse(...) (or fully qualify Quartz.Impl.Recurrence.RecurrenceRule.Parse) in both Validate() and GetParsedRule().

Copilot uses AI. Check for mistakes.
Comment on lines +364 to +367
if (rule.Until != null)
{
return new DateTimeOffset(rule.Until.Value, rule.UntilIsUtc ? TimeSpan.Zero : TimeZone.BaseUtcOffset);
}

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FinalFireTimeUtc converts a local UNTIL value using TimeZone.BaseUtcOffset, which is incorrect for dates that fall under DST (and can produce the wrong UTC instant). Convert using the time zone’s offset for the specific local timestamp (and handle ambiguous/invalid local times consistently with the rule engine’s conversion logic).

Copilot uses AI. Check for mistakes.
{
Recurrence.RecurrenceRule.Parse(recurrenceRuleString);
}
catch (FormatException ex)

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validate() only catches FormatException, but the parser uses int.Parse(...) (and other operations) that can also throw OverflowException/ArgumentException. Consider catching those as well (or catching Exception and wrapping) so callers consistently receive a SchedulerException for invalid RRULE input.

Suggested change
catch (FormatException ex)
catch (Exception ex) when (ex is FormatException || ex is OverflowException || ex is ArgumentException)

Copilot uses AI. Check for mistakes.
Comment on lines +506 to +527
// US Eastern: March 9, 2025, 2:00 AM doesn't exist (clocks jump to 3:00 AM)
RecurrenceRule rule = RecurrenceRule.Parse("FREQ=DAILY");
TimeZoneInfo eastern = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
// Start at 2:30 AM local
DateTimeOffset start = new DateTimeOffset(2025, 3, 8, 7, 30, 0, TimeSpan.Zero); // 2:30 AM EST = 7:30 UTC
DateTimeOffset after = start;

// Next occurrence should be March 9 - time should be adjusted past the gap
DateTimeOffset? next = rule.GetNextOccurrence(start, after, eastern, null);
Assert.IsNotNull(next);
Assert.AreEqual(9, next!.Value.Day);
// The time should be valid (not 2:30 AM which doesn't exist)
Assert.IsFalse(eastern.IsInvalidTime(next.Value.DateTime));
}

[Test]
public void TestDstFallBackAmbiguous()
{
// US Eastern: Nov 2, 2025, 1:30 AM exists twice (clocks fall back at 2:00 AM)
RecurrenceRule rule = RecurrenceRule.Parse("FREQ=DAILY");
TimeZoneInfo eastern = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
// Start at 1:30 AM local on Nov 1

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These DST tests use TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time"), which is Windows-only and will fail on Linux/macOS CI. Use the existing cross-platform helper used elsewhere in the test suite (e.g., TimeZoneUtil.FindTimeZoneById(...) or TZConvert.GetTimeZoneInfo(...)).

Copilot uses AI. Check for mistakes.
Comment on lines +14 to +20
internal sealed class RecurrenceRule
{
private const int MaxIterations = 1000;

private static readonly Dictionary<string, DayOfWeek> DayMap = new(StringComparer.OrdinalIgnoreCase)
{
["MO"] = DayOfWeek.Monday,

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MaxIterations = 1000 causes GetNextOccurrence/GetNthOccurrence to return null for valid RRULEs where the next matching occurrence is more than 1000 base periods away (e.g., FREQ=SECONDLY;BYMONTH=12 when evaluated in January would require scanning ~11 months of seconds). The evaluation should be able to advance to the next possible matching period based on limiting BY* rules (or otherwise ensure the search bound cannot truncate valid schedules).

Copilot uses AI. Check for mistakes.
Comment on lines +167 to +193
case "BYDAY":
byDay = ParseByDay(value);
break;
case "BYMONTHDAY":
byMonthDay = ParseIntList(value);
break;
case "BYMONTH":
byMonth = ParseIntList(value);
break;
case "BYYEARDAY":
byYearDay = ParseIntList(value);
break;
case "BYWEEKNO":
byWeekNo = ParseIntList(value);
break;
case "BYHOUR":
byHour = ParseIntList(value);
break;
case "BYMINUTE":
byMinute = ParseIntList(value);
break;
case "BYSECOND":
bySecond = ParseIntList(value);
break;
case "BYSETPOS":
bySetPos = ParseIntList(value);
break;

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RRULE parsing currently accepts out-of-range BY* values (e.g., BYMONTH=0,13) without failing, and later evaluation often silently skips them (producing an empty candidate set / no firings). To avoid “valid but never fires” configurations and match existing Quartz parsing behavior (e.g., cron), validate BY* ranges during Parse/ParseIntList and throw FormatException on invalid values.

Copilot uses AI. Check for mistakes.
Comment thread src/Quartz/RecurrenceScheduleBuilder.cs Outdated
Action<RecurrenceScheduleBuilder> action)
{
RecurrenceScheduleBuilder builder = RecurrenceScheduleBuilder.Create(recurrenceRule);
action?.Invoke(builder);

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In WithRecurrenceSchedule(..., Action<RecurrenceScheduleBuilder> action), the action parameter is non-nullable but invoked via action?.Invoke(...). Other TriggerBuilder schedule extensions treat the action as required (non-nullable + direct invocation). Either make the parameter nullable (Action<...>?) or remove the null-conditional to match the established pattern (e.g., CronScheduleTriggerBuilderExtensions).

Suggested change
action?.Invoke(builder);
action(builder);

Copilot uses AI. Check for mistakes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 6 comments.

Comment on lines +359 to +362
if (EndTimeUtc != null)
{
return EndTimeUtc;
}

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FinalFireTimeUtc should be the last scheduled fire time on or before EndTimeUtc. Returning EndTimeUtc directly can be incorrect when EndTimeUtc is not aligned to the RRULE (e.g., daily at 09:00 with EndTimeUtc at 08:00). Consider computing the last occurrence <= EndTimeUtc (similar to CronTriggerImpl/CalendarIntervalTriggerImpl behavior) instead of returning the boundary value.

Copilot uses AI. Check for mistakes.
Comment on lines +364 to +368
if (rule.Until != null)
{
if (rule.UntilIsUtc)
{
return new DateTimeOffset(rule.Until.Value, TimeSpan.Zero);

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FinalFireTimeUtc returns the UNTIL boundary value, but UNTIL is an upper bound—not necessarily an occurrence. If UNTIL doesn't land on an actual scheduled fire time, this will report a final fire time that will never fire. Consider computing the last occurrence <= UNTIL (and <= EndTimeUtc if set) instead.

Copilot uses AI. Check for mistakes.
Comment on lines +51 to +57
foreach (int pos in rule.BySetPos)
{
int idx = pos > 0 ? pos - 1 : candidates.Count + pos;
if (idx >= 0 && idx < candidates.Count)
{
DateTime val = candidates[idx];
if (!filtered.Contains(val))

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applying BYSETPOS uses List.Contains() to deduplicate, which is O(n) per element. If the expanded candidate set is large, this becomes O(n^2). Consider using a HashSet (or tracking the last-added value after sorting) to dedupe in O(1) per element.

Suggested change
foreach (int pos in rule.BySetPos)
{
int idx = pos > 0 ? pos - 1 : candidates.Count + pos;
if (idx >= 0 && idx < candidates.Count)
{
DateTime val = candidates[idx];
if (!filtered.Contains(val))
HashSet<DateTime> seen = new HashSet<DateTime>();
foreach (int pos in rule.BySetPos)
{
int idx = pos > 0 ? pos - 1 : candidates.Count + pos;
if (idx >= 0 && idx < candidates.Count)
{
DateTime val = candidates[idx];
if (seen.Add(val))

Copilot uses AI. Check for mistakes.
ITrigger trigger = TriggerBuilder.Create()
.WithIdentity("trigger1", "group1")
.WithRecurrenceSchedule("FREQ=MONTHLY;BYDAY=2MO", b => b
.InTimeZone(TimeZoneInfo.FindSystemTimeZoneById("America/New_York")))

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TimeZoneInfo.FindSystemTimeZoneById("America/New_York") is not portable across OSes (Windows typically expects Windows IDs, Linux/macOS use IANA). Consider using Quartz.Util.TimeZoneUtil.FindTimeZoneById (or document the expected ID format) so the example works cross-platform.

Suggested change
.InTimeZone(TimeZoneInfo.FindSystemTimeZoneById("America/New_York")))
.InTimeZone(Quartz.Util.TimeZoneUtil.FindTimeZoneById("America/New_York")))

Copilot uses AI. Check for mistakes.
private string? timeZoneInfoId
{
get => timeZone?.Id;
set => timeZone = value == null ? null : TimeZoneInfo.FindSystemTimeZoneById(value);

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says this enables cross-platform timezone support, but TimeZoneInfo.FindSystemTimeZoneById will still fail when deserializing an ID not present on the current OS (e.g., Windows vs IANA IDs). Either adjust the comment to match the existing triggers’ caveat, or resolve via TimeZoneUtil.FindTimeZoneById to improve portability.

Suggested change
set => timeZone = value == null ? null : TimeZoneInfo.FindSystemTimeZoneById(value);
set => timeZone = value == null ? null : Quartz.Util.TimeZoneUtil.FindTimeZoneById(value);

Copilot uses AI. Check for mistakes.
Comment on lines +131 to +136
RecurrenceTrigger has two misfire instructions (identical semantics to CronTrigger):

* `MisfireInstruction.IgnoreMisfirePolicy`
* `MisfireInstruction.RecurrenceTrigger.FireOnceNow`
* `MisfireInstruction.RecurrenceTrigger.DoNothing`

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The text says “two misfire instructions” but three bullets are listed (including IgnoreMisfirePolicy). Consider rewording to clarify that there are two RecurrenceTrigger-specific instructions, plus the generic IgnoreMisfirePolicy.

Copilot uses AI. Check for mistakes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 3 comments.

Comment on lines +269 to +302
for (int i = 0; i < MaxIterations; i++)
{
DateTime periodStart = GetPeriodStart(local.Start, i);
List<DateTime> candidates = ByRuleExpander.ExpandPeriod(this, local.Start, periodStart);

foreach (DateTime candidate in candidates)
{
if (candidate < local.Start)
{
continue;
}
if (local.Until != null && candidate > local.Until.Value)
{
return null;
}
if (local.End != null && candidate > local.End.Value)
{
return null;
}

occurrenceCount++;
if (occurrenceCount == limit)
{
// Return the candidate only if we actually reached the requested N;
// if limit < n (because COUNT < n), this is the COUNT-th occurrence
// but not the requested Nth, so return null.
return occurrenceCount == n
? ToDateTimeOffset(candidate, local.TimeZone)
: (DateTimeOffset?)null;
}
}
}

return null;

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetNthOccurrence caps the search to MaxIterations (1000) periods. For COUNT-based rules with large COUNT values (e.g., daily COUNT>1000), this can return null even though the Nth occurrence exists, which makes FinalFireTimeUtc incorrect. Consider making the iteration bound adaptive to n/rule density (or explicitly validating/rejecting COUNT values that exceed the supported bound with a clear exception).

Copilot uses AI. Check for mistakes.

namespace Quartz.Triggers;

internal sealed class RecurrenceTriggerSerializer : TriggerSerializer<IRecurrenceTrigger>

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this project, other trigger serializers (CronTriggerSerializer, SimpleTriggerSerializer, etc.) are public, but RecurrenceTriggerSerializer is internal. If consumers are expected to be able to reference/extend trigger serializers consistently, this should likely be public as well (or make the visibility consistent across all serializers).

Suggested change
internal sealed class RecurrenceTriggerSerializer : TriggerSerializer<IRecurrenceTrigger>
public sealed class RecurrenceTriggerSerializer : TriggerSerializer<IRecurrenceTrigger>

Copilot uses AI. Check for mistakes.

namespace Quartz.Serialization.Json.Triggers;

internal sealed class RecurrenceTriggerSerializer : TriggerSerializer<IRecurrenceTrigger>

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this project, other trigger serializers (CronTriggerSerializer, SimpleTriggerSerializer, etc.) are public, but RecurrenceTriggerSerializer is internal. If consumers are expected to be able to reference/extend trigger serializers consistently, this should likely be public as well (or make the visibility consistent across all serializers).

Suggested change
internal sealed class RecurrenceTriggerSerializer : TriggerSerializer<IRecurrenceTrigger>
public sealed class RecurrenceTriggerSerializer : TriggerSerializer<IRecurrenceTrigger>

Copilot uses AI. Check for mistakes.
lahma and others added 7 commits April 3, 2026 14:21
Add a new RecurrenceTrigger that fires based on iCalendar RFC 5545
recurrence rules (RRULE), enabling complex scheduling patterns that
cannot be expressed with CRON expressions such as "every 2nd Monday
of the month" or "last weekday of March each year".

Key components:
- Internal RRULE engine with lazy GetNextOccurrence() evaluation
- IRecurrenceTrigger interface and RecurrenceTriggerImpl
- RecurrenceScheduleBuilder with TriggerBuilder extensions
- Persistence via SIMPROP_TRIGGERS (no schema changes needed)
- JSON serializers for both System.Text.Json and Newtonsoft.Json
- DI integration via WithRecurrenceSchedule() extension

Closes #1259
Supersedes #1276

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Add tutorial page covering RRULE syntax, usage examples, timezone
support, DI configuration, misfire instructions, and comparison
with other trigger types.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Critical fixes:
- Make GetParsedRule() thread-safe via volatile field
- Make TimesTriggered the single source of truth for COUNT tracking
  by adding skipCount parameter to GetNextOccurrence
- Fix FinalFireTimeUtc for COUNT-based rules via new GetNthOccurrence

Moderate fixes:
- Remove dead code in DST gap handling (unused offsets variable)
- Remove bare catch, use GetAmbiguousTimeOffsets directly
- Add explicit values to RecurrenceFrequency enum with doc noting
  the ordering dependency in ByRuleExpander

Minor fixes:
- Make persistence delegate public sealed (matches other delegates)
- Tolerate unknown RRULE properties instead of throwing
- Don't renumber existing tutorial lessons in README

New tests:
- DST spring-forward gap and fall-back ambiguous time
- BYMONTHDAY=31 skipping February
- COUNT skipCount parameter behavior
- GetNthOccurrence correctness
- Persistence delegate round-trip
- FinalFireTimeUtc with COUNT, EndTime, and no-end
- COUNT exhaustion via TimesTriggered
- Unknown RRULE property tolerance

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Remove unused `using System.Threading` import
- Add comment explaining skipCount=false is safe in ComputeFirstFireTimeUtc
- Fix TestCalendarExclusion to actually verify skip behavior using AnnualCalendar
- Strengthen RecurrenceRule immutability doc (arrays are internal, treat as read-only)
- Simplify GetNthOccurrence to single limit check with clear null semantics

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Fix FinalFireTimeUtc using BaseUtcOffset (ignores DST) — now uses
  GetUtcOffset for the specific UNTIL date with DST gap handling
- Widen Validate() catch to include OverflowException/ArgumentException
  from int.Parse on malformed RRULE values
- Use TimeZoneUtil.FindTimeZoneById in DST tests for cross-platform
  compatibility (Linux/macOS CI)
- Add BY* range validation in parser: BYMONTH 1-12, BYHOUR 0-23,
  BYMINUTE 0-59, BYSECOND 0-59, BYMONTHDAY -31..31 (no zero), etc.
  Rejects invalid values with FormatException instead of silently
  producing triggers that never fire
- Remove null-conditional on non-nullable action parameter in
  WithRecurrenceSchedule extension to match project conventions
- Add fast-forward optimization for sub-daily frequencies with BYMONTH
  limiting (e.g. FREQ=HOURLY;BYMONTH=12 starting in January) to skip
  non-matching months instead of iterating every period

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Fix FinalFireTimeUtc returning boundary values (EndTimeUtc/UNTIL)
  that may not be actual fire times. Now uses GetLastOccurrenceBefore
  which searches backward from the boundary to find the last real
  occurrence. Handles misaligned boundaries correctly (e.g. daily
  at 9:00 with EndTime at 8:00 returns the previous day's 9:00)
- Use TimeZoneUtil.FindTimeZoneById in timeZoneInfoId setter for
  cross-platform timezone resolution (Windows/IANA ID mapping)
- Use HashSet instead of List.Contains for BYSETPOS deduplication
- Fix doc timezone example to use TimeZoneUtil.FindTimeZoneById
- Fix doc "two misfire instructions" wording to clarify there are
  two trigger-specific plus generic IgnoreMisfirePolicy
- Add test for FinalFireTimeUtc with misaligned EndTime

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Make RecurrenceTriggerSerializer public sealed in both
  System.Text.Json and Newtonsoft.Json projects to match
  visibility of other trigger serializers
- Fix GetNthOccurrence iteration bound: use max(1000, limit+1000)
  so COUNT values > 1000 can find their final occurrence for
  FinalFireTimeUtc

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 3 comments.

Comment on lines +160 to +163
private static void ExpandWeekly(RecurrenceRule rule, DateTime dtStart, DateTime periodStart, List<DateTime> candidates)
{
// WEEKLY: BYDAY expands, everything else limits
List<DateTime> days = new List<DateTime>();

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExpandWeekly states "everything else limits" but the implementation only applies a BYMONTH filter and then expands times. As written, other parsed BY* limit rules (e.g., BYMONTHDAY) are ignored for WEEKLY rules, which can produce incorrect candidate sets and contradicts the method comment. Consider applying the remaining limit rules for WEEKLY (or tightening the parser to reject unsupported BY* combinations) and/or updating the comment to match actual behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +269 to +272
// Iteration bound must be at least n (each period may produce only one occurrence),
// capped at a reasonable maximum to prevent runaway for pathological rules.
int maxPeriods = Math.Max(MaxIterations, limit + MaxIterations);

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In GetNthOccurrence, the comment says the iteration bound is "capped at a reasonable maximum", but maxPeriods is computed via Math.Max(MaxIterations, limit + MaxIterations), which grows without bound as COUNT increases. This can make FinalFireTimeUtc (which calls GetNthOccurrence for COUNT rules) potentially very expensive for large COUNT values. Consider enforcing a real upper bound (e.g., a hard cap, or a cap based on the same "give up" year logic used by other triggers) and aligning the comment with the actual behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +397 to +402

// Validate string length for persistence (SIMPROP_TRIGGERS String1 is 512 chars)
if (recurrenceRuleString.Length > 512)
{
throw new SchedulerException("RecurrenceRule string exceeds maximum length of 512 characters for database persistence.");
}

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validate() enforces a 512-character RRULE limit based on the ADO.NET SIMPROP_TRIGGERS schema. Since Validate() is called regardless of the configured job store, this prevents using longer RRULE strings even with RAMJobStore or JSON-only scenarios. Consider moving this constraint into the ADO persistence path (e.g., the persistence delegate / JobStoreSupport) or making it conditional so non-ADO usage isn’t artificially restricted.

Suggested change
// Validate string length for persistence (SIMPROP_TRIGGERS String1 is 512 chars)
if (recurrenceRuleString.Length > 512)
{
throw new SchedulerException("RecurrenceRule string exceeds maximum length of 512 characters for database persistence.");
}

Copilot uses AI. Check for mistakes.
- Clarify ExpandWeekly comment to list exact RFC 5545 expand/limit
  rules instead of misleading "everything else limits"
- Hard-cap GetNthOccurrence at 100K periods to prevent unbounded
  iteration for pathological COUNT values
- Move 512-char RRULE length validation from Validate() to the
  persistence delegate so RAMJobStore and JSON-only usage aren't
  artificially restricted

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 3 comments.

Comment on lines +31 to +35
// SIMPROP_TRIGGERS STRING_PROP_1 column is typically 512 chars
if (recTrig.RecurrenceRule.Length > 512)
{
throw new JobPersistenceException(
"RecurrenceRule string exceeds maximum length of 512 characters for database persistence.");

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment refers to the SIMPROP_TRIGGERS column as STRING_PROP_1, but the Quartz schema uses STR_PROP_1 (e.g., database/sqlserver_schema_10_to_20_upgrade.sql defines STR_PROP_1 VARCHAR(512)). Please update the comment to the correct column name to avoid confusion when troubleshooting persistence issues.

Copilot uses AI. Check for mistakes.
### Persistence

RecurrenceTrigger uses the existing `QRTZ_SIMPROP_TRIGGERS` table for persistence - no database schema changes are required.
The RRULE string is stored in the `STRING_PROP_1` column (max 512 characters). The trigger type discriminator is `RECUR`.

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tutorial says the RRULE is stored in the STRING_PROP_1 column, but the actual QRTZ_SIMPROP_TRIGGERS schema uses STR_PROP_1 (length 512). Please correct the column name in the docs to match the database scripts.

Suggested change
The RRULE string is stored in the `STRING_PROP_1` column (max 512 characters). The trigger type discriminator is `RECUR`.
The RRULE string is stored in the `STR_PROP_1` column (max 512 characters). The trigger type discriminator is `RECUR`.

Copilot uses AI. Check for mistakes.
Comment on lines +819 to +830
int ordinal = 0;
if (item.Length > 2)
{
string ordStr = item.Substring(0, item.Length - 2);
if (!int.TryParse(ordStr, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out ordinal))
{
throw new FormatException($"Invalid ordinal in BYDAY: '{ordStr}'");
}
}

result[i] = (day, ordinal);
}

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ParseByDay accepts any integer ordinal (e.g. BYDAY=100MO) without validating the RFC 5545 allowed range (typically -53..53, excluding 0). This can silently create rules that never produce occurrences instead of failing fast like the other BY* parsers do. Consider validating the ordinal range and throwing FormatException for out-of-range values.

Copilot uses AI. Check for mistakes.
- Fix STR_PROP_1 column name (was incorrectly STRING_PROP_1) in
  persistence delegate comment and tutorial docs
- Validate BYDAY ordinal range -53..53 excluding 0 per RFC 5545,
  matching the validation applied to other BY* rules

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@lahma
lahma requested a review from Copilot April 3, 2026 11:45

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 3 comments.

Comment on lines +104 to +110
```csharp
ITrigger trigger = TriggerBuilder.Create()
.WithIdentity("trigger1", "group1")
.WithRecurrenceSchedule("FREQ=MONTHLY;BYDAY=2MO", b => b
.InTimeZone(Quartz.Util.TimeZoneUtil.FindTimeZoneById("America/New_York")))
.StartNow()
.Build();

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs example uses TimeZoneUtil.FindTimeZoneById("America/New_York"), but TimeZoneUtil only aliases a limited set of Windows/IANA IDs and does not include America/New_York. On Windows this will throw TimeZoneNotFoundException. Consider switching the example to an ID that TimeZoneUtil can resolve cross-platform (e.g., Eastern Standard Time / US/Eastern) and/or add a note about using Quartz.Plugins.TimeZoneConverter when relying on IANA IDs on Windows.

Copilot uses AI. Check for mistakes.
Comment on lines +387 to +396

// Validate that the RRULE string is parseable
try
{
Recurrence.RecurrenceRule.Parse(recurrenceRuleString);
}
catch (Exception ex) when (ex is FormatException or OverflowException or ArgumentException)
{
throw new SchedulerException($"Invalid RecurrenceRule: {ex.Message}", ex);
}

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recurrence.RecurrenceRule.Parse(...) relies on namespace-resolution to bind Recurrence to the sibling namespace Quartz.Impl.Recurrence, which is non-obvious and easy to misread as a type reference. For clarity and to avoid future ambiguity (e.g., if a Recurrence type is introduced), prefer fully qualifying the type (Quartz.Impl.Recurrence.RecurrenceRule.Parse(...)) or adding a using Recurrence = Quartz.Impl.Recurrence; alias and using that consistently.

Copilot uses AI. Check for mistakes.
Comment on lines +421 to +429
private RecurrenceRule GetParsedRule()
{
RecurrenceRule? rule = parsedRule;
if (rule == null)
{
rule = Recurrence.RecurrenceRule.Parse(recurrenceRuleString);
parsedRule = rule;
}
return rule;

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same namespace-resolution concern here: Recurrence.RecurrenceRule.Parse(...) is hard to read and depends on resolving Recurrence as the Quartz.Impl.Recurrence namespace. Consider fully qualifying the type or using a namespace alias for clarity.

Copilot uses AI. Check for mistakes.
- Add `using RRule = Quartz.Impl.Recurrence.RecurrenceRule` alias
  to disambiguate the internal class from the string property,
  replacing the non-obvious `Recurrence.RecurrenceRule.Parse(...)`
  namespace resolution pattern
- Fix doc timezone example: use "Eastern Standard Time" which
  TimeZoneUtil resolves cross-platform, instead of IANA ID
  "America/New_York" which requires TimeZoneConverter plugin

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@lahma
lahma requested a review from Copilot April 3, 2026 12:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

port-main Requires porting to main branch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants