Add RFC 5545 RRULE recurrence trigger support#2990
Conversation
There was a problem hiding this comment.
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/RecurrenceTriggerImplplusRecurrenceScheduleBuilderandWithRecurrenceSchedule(...)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 |
| // Validate that the RRULE string is parseable | ||
| try | ||
| { | ||
| Recurrence.RecurrenceRule.Parse(recurrenceRuleString); | ||
| } | ||
| catch (FormatException ex) |
There was a problem hiding this comment.
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().
| if (rule.Until != null) | ||
| { | ||
| return new DateTimeOffset(rule.Until.Value, rule.UntilIsUtc ? TimeSpan.Zero : TimeZone.BaseUtcOffset); | ||
| } |
There was a problem hiding this comment.
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).
| { | ||
| Recurrence.RecurrenceRule.Parse(recurrenceRuleString); | ||
| } | ||
| catch (FormatException ex) |
There was a problem hiding this comment.
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.
| catch (FormatException ex) | |
| catch (Exception ex) when (ex is FormatException || ex is OverflowException || ex is ArgumentException) |
| // 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 |
There was a problem hiding this comment.
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(...)).
| internal sealed class RecurrenceRule | ||
| { | ||
| private const int MaxIterations = 1000; | ||
|
|
||
| private static readonly Dictionary<string, DayOfWeek> DayMap = new(StringComparer.OrdinalIgnoreCase) | ||
| { | ||
| ["MO"] = DayOfWeek.Monday, |
There was a problem hiding this comment.
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).
| 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; |
There was a problem hiding this comment.
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.
| Action<RecurrenceScheduleBuilder> action) | ||
| { | ||
| RecurrenceScheduleBuilder builder = RecurrenceScheduleBuilder.Create(recurrenceRule); | ||
| action?.Invoke(builder); |
There was a problem hiding this comment.
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).
| action?.Invoke(builder); | |
| action(builder); |
| if (EndTimeUtc != null) | ||
| { | ||
| return EndTimeUtc; | ||
| } |
There was a problem hiding this comment.
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.
| if (rule.Until != null) | ||
| { | ||
| if (rule.UntilIsUtc) | ||
| { | ||
| return new DateTimeOffset(rule.Until.Value, TimeSpan.Zero); |
There was a problem hiding this comment.
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.
| 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)) |
There was a problem hiding this comment.
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.
| 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)) |
| ITrigger trigger = TriggerBuilder.Create() | ||
| .WithIdentity("trigger1", "group1") | ||
| .WithRecurrenceSchedule("FREQ=MONTHLY;BYDAY=2MO", b => b | ||
| .InTimeZone(TimeZoneInfo.FindSystemTimeZoneById("America/New_York"))) |
There was a problem hiding this comment.
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.
| .InTimeZone(TimeZoneInfo.FindSystemTimeZoneById("America/New_York"))) | |
| .InTimeZone(Quartz.Util.TimeZoneUtil.FindTimeZoneById("America/New_York"))) |
| private string? timeZoneInfoId | ||
| { | ||
| get => timeZone?.Id; | ||
| set => timeZone = value == null ? null : TimeZoneInfo.FindSystemTimeZoneById(value); |
There was a problem hiding this comment.
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.
| set => timeZone = value == null ? null : TimeZoneInfo.FindSystemTimeZoneById(value); | |
| set => timeZone = value == null ? null : Quartz.Util.TimeZoneUtil.FindTimeZoneById(value); |
| RecurrenceTrigger has two misfire instructions (identical semantics to CronTrigger): | ||
|
|
||
| * `MisfireInstruction.IgnoreMisfirePolicy` | ||
| * `MisfireInstruction.RecurrenceTrigger.FireOnceNow` | ||
| * `MisfireInstruction.RecurrenceTrigger.DoNothing` | ||
|
|
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
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).
|
|
||
| namespace Quartz.Triggers; | ||
|
|
||
| internal sealed class RecurrenceTriggerSerializer : TriggerSerializer<IRecurrenceTrigger> |
There was a problem hiding this comment.
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).
| internal sealed class RecurrenceTriggerSerializer : TriggerSerializer<IRecurrenceTrigger> | |
| public sealed class RecurrenceTriggerSerializer : TriggerSerializer<IRecurrenceTrigger> |
|
|
||
| namespace Quartz.Serialization.Json.Triggers; | ||
|
|
||
| internal sealed class RecurrenceTriggerSerializer : TriggerSerializer<IRecurrenceTrigger> |
There was a problem hiding this comment.
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).
| internal sealed class RecurrenceTriggerSerializer : TriggerSerializer<IRecurrenceTrigger> | |
| public sealed class RecurrenceTriggerSerializer : TriggerSerializer<IRecurrenceTrigger> |
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]>
| 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>(); |
There was a problem hiding this comment.
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.
| // 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); | ||
|
|
There was a problem hiding this comment.
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.
|
|
||
| // 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."); | ||
| } |
There was a problem hiding this comment.
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.
| // 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."); | |
| } |
- 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]>
| // 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."); |
There was a problem hiding this comment.
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.
| ### 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`. |
There was a problem hiding this comment.
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.
| 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`. |
| 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); | ||
| } |
There was a problem hiding this comment.
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.
- 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]>
| ```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(); |
There was a problem hiding this comment.
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.
|
|
||
| // 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); | ||
| } |
There was a problem hiding this comment.
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.
| private RecurrenceRule GetParsedRule() | ||
| { | ||
| RecurrenceRule? rule = parsedRule; | ||
| if (rule == null) | ||
| { | ||
| rule = Recurrence.RecurrenceRule.Parse(recurrenceRuleString); | ||
| parsedRule = rule; | ||
| } | ||
| return rule; |
There was a problem hiding this comment.
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.
- 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]>
Summary
RecurrenceTriggerthat fires based on iCalendar RFC 5545 recurrence rules (RRULE), enabling complex scheduling patterns that cannot be expressed with CRON expressionsGetNextOccurrence()evaluation — no new external dependenciesExamples of patterns now possible
FREQ=MONTHLY;BYDAY=2MOFREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FRFREQ=YEARLY;BYMONTH=3;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1FREQ=MONTHLY;BYMONTHDAY=-1FREQ=DAILY;BYDAY=MO,TU,WE,TH,FRUsage
Components added
IRecurrenceTriggerRecurrenceTriggerImplRecurrenceScheduleBuilderWithRecurrenceSchedule()extensionsRecurrenceRule(internal)ByRuleExpander(internal)WithRecurrenceSchedule()onITriggerConfiguratorBackground
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
🤖 Generated with Claude Code