From 9b664215720ffe6eda8bc06e732ff529c847bc51 Mon Sep 17 00:00:00 2001 From: Justin Williamson Date: Sun, 5 Oct 2025 23:38:55 -0400 Subject: [PATCH 1/8] Support schedule jitter --- README.md | 94 ++++++++------ .../Cronos.Benchmarks/CronBenchmarks.cs | 12 ++ src/Cronos/CronExpression.cs | 57 ++++++-- src/Cronos/CronField.cs | 1 + src/Cronos/CronFormat.cs | 5 +- src/Cronos/CronParser.cs | 122 +++++++++++++----- tests/Cronos.Tests/CronExpressionFacts.cs | 52 ++++++++ 7 files changed, 265 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 36cfcc8..18153c5 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Cronos is a .NET library for parsing Cron expressions and calculating next occur * Supports standard Cron format with optional seconds. * Supports non-standard characters like `L`, `W`, `#` and their combinations. +* Supports schedule jitter via the `H` character, inspired by Jenkins. * Supports reversed ranges, like `23-01` (equivalent to `23,00,01`) or `DEC-FEB` (equivalent to `DEC,JAN,FEB`). * Supports time zones, and performs all the date/time conversions for you. * Does not skip occurrences, when the clock jumps forward to Daylight saving time (known as Summer time). @@ -16,14 +17,14 @@ Cronos is a .NET library for parsing Cron expressions and calculating next occur ## Compatibility -This section explains how Cron expressions should be converted, when moving to Cronos. +This section explains how Cron expressions should be converted when moving to Cronos. -Library | Comments ---- | --- -Vixie Cron | When both day-of-month and day-of-week are specified, Cronos uses AND operator for matching (Vixie Cron uses OR operator for backward compatibility). -Quartz.NET | Cronos uses different, but more intuitive Daylight saving time handling logic (as in Vixie Cron). Full month names such as `september` aren't supported. Day-of-week field in Cronos has different values, `0` and `7` stand for Sunday, `1` for Monday, etc. (as in Vixie Cron). Year field is not supported. -NCrontab | Compatible -CronNET | Compatible +| Library | Comments | +|------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Vixie Cron | When both day-of-month and day-of-week are specified, Cronos uses AND operator for matching (Vixie Cron uses OR operator for backward compatibility). | +| Quartz.NET | Cronos uses different, but more intuitive Daylight saving time handling logic (as in Vixie Cron). Full month names such as `september` aren't supported. Day-of-week field in Cronos has different values, `0` and `7` stand for Sunday, `1` for Monday, etc. (as in Vixie Cron). Year field is not supported. | +| NCrontab | Compatible | +| CronNET | Compatible | ## Installation @@ -35,7 +36,7 @@ PM> Install-Package Cronos ## Usage -We've tried to do our best to make Cronos API as simple and predictable in corner cases as possible. So you can only use `DateTime` with `DateTimeKind.Utc` specified (for example, `DateTime.UtcNow`), or `DateTimeOffset` classes to calculate next occurrences. You **can not use** local `DateTime` objects (such as `DateTime.Now`), because this may lead to ambiguity during DST transitions, and an exception will be thrown if you attempt to use them. +We've tried to do our best to make Cronos API as simple and predictable in corner cases as possible. So you can only use `DateTime` with `DateTimeKind.Utc` specified (for example, `DateTime.UtcNow`), or `DateTimeOffset` classes to calculate next occurrences. You **cannot use** local `DateTime` objects (such as `DateTime.Now`), because this may lead to ambiguity during DST transitions, and an exception will be thrown if you attempt to use them. To calculate the next occurrence, you need to create an instance of the `CronExpression` class, and call its `GetNextOccurrence` method. To learn about Cron format, please refer to the next section. @@ -51,7 +52,7 @@ The `nextUtc` will contain the next occurrence in UTC, *after the given time*, o ### Working with time zones -It is possible to specify a time zone directly, in this case you should pass `DateTime` with `DateTimeKind.Utc` flag, or use `DateTimeOffset` class, that's is smart enough to always point to an exact, non-ambiguous instant. +It is possible to specify a time zone directly; in this case you should pass `DateTime` with `DateTimeKind.Utc` flag, or use `DateTimeOffset` class, since that is smart enough to always point to an exact, non-ambiguous instant. ```csharp CronExpression expression = CronExpression.Parse("* * * * *"); @@ -104,22 +105,24 @@ Cron expression is a mask to define fixed times, dates and intervals. The mask c Allowed values Allowed special characters Comment - ┌───────────── second (optional) 0-59 * , - / - │ ┌───────────── minute 0-59 * , - / - │ │ ┌───────────── hour 0-23 * , - / - │ │ │ ┌───────────── day of month 1-31 * , - / L W ? - │ │ │ │ ┌───────────── month 1-12 or JAN-DEC * , - / - │ │ │ │ │ ┌───────────── day of week 0-6 or SUN-SAT * , - / # L ? Both 0 and 7 means SUN + ┌───────────── second (optional) 0-59 * , - / H + │ ┌───────────── minute 0-59 * , - / H + │ │ ┌───────────── hour 0-23 * , - / H + │ │ │ ┌───────────── day of month 1-31 * , - / H L W ? + │ │ │ │ ┌───────────── month 1-12 or JAN-DEC * , - / H + │ │ │ │ │ ┌───────────── day of week 0-6 or SUN-SAT * , - / H # L ? Both 0 and 7 means SUN │ │ │ │ │ │ * * * * * * ### Base characters -In all fields you can use number, `*` to mark field as *any value*, `-` to specify ranges of values. Reversed ranges like `22-1`(equivalent to `22,23,0,1,2`) are also supported. +In all fields you can use numbers, `*` to mark a field as *every value*, and `-` to specify ranges of values. Reversed ranges like `22-1` (equivalent to `22,23,0,1,2`) are also supported. -It's possible to define **step** combining `/` with `*`, numbers and ranges. For example, `*/5` in minute field describes *every 5 minute* and `1-15/3` in day-of-month field – *every 3 days from the 1st to the 15th*. Pay attention that `*/24` is just equivalent to `0,24,48` and `*/24` in minute field doesn't literally mean *every 24 minutes* it means *every 0,24,48 minute*. +You can also use `H` to choose a *single value* left up to the implementation, for use cases where you might want to distribute load. This is a form a [schedule jitter](#jitter). -Concatinate values and ranges by `,`. Comma works like `OR` operator. So `3,5-11/3,12` is equivalent to `3,5,8,11,12`. +It's possible to define **steps** by combining `/` with `*`, `H`, numbers and ranges. For example, `*/5` in the minute field describes *every 5 minutes* and `1-15/3` in day-of-month field describes *every 3 days from the 1st to the 15th*. Pay attention that `*/24` is just equivalent to `0,24,48` and `*/24` in minute field doesn't literally mean *every 24 minutes* - it means *every 0,24,48 minute*. + +Concatenate values and ranges by `,`. Comma works like `OR` operator. So `3,5-11/3,12` is equivalent to `3,5,8,11,12`. In month and day-of-week fields, you can use names of months or days of weeks abbreviated to first three letters (`Jan-Dec` or `Mon-Sun`) instead of their numeric values. Full names like `JANUARY` or `MONDAY` **aren't supported**. @@ -139,7 +142,7 @@ Most expressions you can describe using base characters. If you want to deal wit **`L`** stands for "last". When used in the day-of-week field, it allows you to specify constructs such as *the last Friday* (`5L`or `FRIL`). In the day-of-month field, it specifies the last day of the month. -**`W`** in day-of-month field is the nearest weekday. Use `W` with single value (not ranges, steps or `*`) to define *the nearest weekday* to the given day. In this case there are two base rules to determine occurrence: we should shift to **the nearest weekday** and **can't shift to different month**. Thus if given day is Saturday we shift to Friday, if it is Sunday we shift to Monday. **But** if given day is **the 1st day of month** (e.g. `0 0 1W * *`) and it is Saturday we shift to the 3rd Monday, if given day is **last day of month** (`0 0 31W 0 0`) and it is Sunday we shift to that Friday. Mix `L` (optionaly with offset) and `W` characters to specify *last weekday of month* `LW` or more complex like `L-5W`. +**`W`** in day-of-month field is the nearest weekday. Use `W` with single value (not ranges, steps or `*`) to define *the nearest weekday* to the given day. In this case there are two base rules to determine occurrence: we should shift to **the nearest weekday** and **can't shift to different month**. Thus if given day is Saturday we shift to Friday, if it is Sunday we shift to Monday. **But** if given day is **the 1st day of month** (e.g. `0 0 1W * *`) and it is Saturday we shift to the 3rd Monday, if given day is **last day of month** (`0 0 31W 0 0`) and it is Sunday we shift to that Friday. Mix `L` (optionally with offset) and `W` characters to specify *last weekday of month* `LW` or more complex like `L-5W`. **`#`** in day-of-week field allows to specify constructs such as *second Saturday* (`6#2` or `SAT#2`). @@ -159,23 +162,23 @@ Most expressions you can describe using base characters. If you want to deal wit You can set both **day-of-month** and **day-of-week**, it allows you to specify constructs such as **Friday the thirteenth**. Thus `0 0 13 * 5` means at 00:00, Friday the thirteenth. -It differs from Unix crontab and Quartz cron implementations. Crontab handles it like `OR` operator: occurrence can happen in given day of month or given day of week. So `0 0 13 * 5` means *at 00:00 AM, every friday or every the 13th of a month*. Quartz doesn't allow specify both day-of-month and day-of-week. +It differs from Unix crontab and Quartz cron implementations. Crontab handles it like `OR` operator: occurrence can happen in given day of month or given day of week. So `0 0 13 * 5` means *at 00:00 AM, every friday or every the 13th of a month*. Quartz doesn't allow specifying both day-of-month and day-of-week. ### Macro A macro is a string starting with `@` and representing a shortcut for simple cases like *every day* or *every minute*. - Macro | Equivalent | Comment -----------------|---------------| ------- -`@every_second` | `* * * * * *` | Run once a second -`@every_minute` | `* * * * *` | Run once a minute at the beginning of the minute -`@hourly` | `0 * * * *` | Run once an hour at the beginning of the hour -`@daily` | `0 0 * * *` | Run once a day at midnight -`@midnight` | `0 0 * * *` | Run once a day at midnight -`@weekly` | `0 0 * * 0` | Run once a week at midnight on Sunday morning -`@monthly` | `0 0 1 * *` | Run once a month at midnight of the first day of the month -`@yearly` | `0 0 1 1 *` | Run once a year at midnight of 1 January -`@annually` | `0 0 1 1 *` | Run once a year at midnight of 1 January +| Macro | Equivalent | Comment | +|-----------------|---------------|------------------------------------------------------------| +| `@every_second` | `* * * * * *` | Run once a second | +| `@every_minute` | `* * * * *` | Run once a minute at the beginning of the minute | +| `@hourly` | `0 * * * *` | Run once an hour at the beginning of the hour | +| `@daily` | `0 0 * * *` | Run once a day at midnight | +| `@midnight` | `0 0 * * *` | Run once a day at midnight | +| `@weekly` | `0 0 * * 0` | Run once a week at midnight on Sunday morning | +| `@monthly` | `0 0 1 * *` | Run once a month at midnight of the first day of the month | +| `@yearly` | `0 0 1 1 *` | Run once a year at midnight of 1 January | +| `@annually` | `0 0 1 1 *` | Run once a year at midnight of 1 January | ### Cron grammar @@ -187,12 +190,12 @@ Cronos parser uses following case-insensitive grammar: second ::= field minute ::= field hour ::= field -day-of-month ::= '*' [step] | '?' [step] | lastday | value [ 'W' | range [list] ] +day-of-month ::= '*' [step] | '?' [step] | 'H' [step] | lastday | value [ 'W' | range [list] ] month ::= field - day-of-week ::= '*' [step] | '?' [step] | value [ dowspec | range [list] ] + day-of-week ::= '*' [step] | '?' [step] | 'H' [step] | value [ dowspec | range [list] ] macro ::= '@every_second' | '@every_minute' | '@hourly' | '@daily' | '@midnight' | '@weekly' | '@monthly'| '@yearly' | '@annually' - field ::= '*' [step] | '?' [step] | value [range] [list] + field ::= '*' [step] | '?' [step] | 'H' [step] | value [range] [list] list ::= { ',' value [range] } range ::= '-' value [step] | [step] step ::= '/' number @@ -208,13 +211,13 @@ day-of-month ::= '*' [step] | '?' [step] | lastday | value [ 'W' | range [list] ## Daylight Saving Time -Cronos is the only library to handle daylight saving time transitions in intuitive way with the same behavior as Vixie Cron (utility for *nix systems). During a spring transition, we don't skip occurrences scheduled to invalid time during. In an autumn transition we don't get duplicate occurrences for daily expressions, and don't skip interval expressions when the local time is ambiguous. +Cronos is the only library to handle daylight saving time transitions in an intuitive way with the same behavior as Vixie Cron (utility for *nix systems). During a spring transition, we don't skip occurrences scheduled to invalid time during. In an autumn transition we don't get duplicate occurrences for daily expressions, and don't skip interval expressions when the local time is ambiguous. ### Transition to Summer time (in spring) During the transition to Summer time, the clock is moved forward, for example the next minute after `01:59 AM` is `03:00 AM`. So any daily Cron expression that should match `02:30 AM`, points to an invalid time. It doesn't exist, and can't be mapped to UTC. -Cronos adjusts the next occurrence to the next valid time in these cases. If you use Cron to schedule jobs, you may have shorter or longer intervals between runs when this happen, but you'll not lose your jobs: +Cronos adjusts the next occurrence to the next valid time in these cases. If you use Cron to schedule jobs, you may have shorter or longer intervals between runs when this happens, but you'll not lose your jobs: ``` "30 02 * * *" (every day at 02:30 AM) @@ -258,6 +261,25 @@ Nov 08, 01:30 +03:00 – skip Nov 09, 01:30 +03:00 – run ``` + +## Jitter + +Cronos supports the ability to distribute cron fields randomly in order to spread out system load over time, a feature called "schedule jitter". You can opt into this capability by passing in a seed for a random number generator and optionally using the special character `H` in a cron expression. Using `H` in an expression while failing to provide a seed will throw an exception. + +Just as it is possible to generate impossible combinations in basic cron expressions (e.g. `* * 31 2 *` being February 31st), care should be taken when combining an `H` with other fields. One common protection is built-in: when `H` is used for the day of the month, the range is limited to the first 28 days of the month. However, expressions like `* * 31 H *` (i.e. the 31st day of a random month) share the same limitations as `* * 31 * *` for months that don't have a 31st day. + +The presence of a jitter seed also adjusts the behavior of some macros by offsetting the times by a random amount: + +| Macro | Equivalent | Comment | +|-----------------|---------------|---------------------------------------------| +| `@every_minute` | `H * * * * *` | Run once a minute at an unspecified time | +| `@hourly` | `H H * * * *` | Run once an hour at an unspecified time | +| `@daily` | `H H H * * *` | Run once a day at an unspecified time | +| `@weekly` | `H H H * * H` | Run once a week at an unspecified day/time | +| `@monthly` | `H H H H * *` | Run once a month at an unspecified day/time | +| `@yearly` | `H H H H H *` | Run once a year at an unspecified day/time | +| `@annually` | `H H H H H *` | Run once a year at an unspecified day/time | + ## Benchmarks Since [BenchmarkDotNet](https://github.com/dotnet/BenchmarkDotNet) project appeared, it's hard to ignore the performance. We tried hard to make Cronos not only feature-rich, but also really fast when parsing expressions and calculating next occurrences. As a result, Cronos is faster more than in an order of magnitude than alternative libraries, here is a small comparison: diff --git a/benchmarks/Cronos.Benchmarks/CronBenchmarks.cs b/benchmarks/Cronos.Benchmarks/CronBenchmarks.cs index 1dd2229..2958dff 100644 --- a/benchmarks/Cronos.Benchmarks/CronBenchmarks.cs +++ b/benchmarks/Cronos.Benchmarks/CronBenchmarks.cs @@ -107,6 +107,18 @@ public CronExpression ParseList() return CronExpression.Parse("20,30,40,50 * * * *"); } + [Benchmark] + public CronExpression ParseHash() + { + return CronExpression.Parse("H * * * *"); + } + + [Benchmark] + public CronExpression ParseMultipleHash() + { + return CronExpression.Parse("H H H H H"); + } + [Benchmark] public CronExpression ParseComplex() { diff --git a/src/Cronos/CronExpression.cs b/src/Cronos/CronExpression.cs index 7d8b43e..dff6747 100644 --- a/src/Cronos/CronExpression.cs +++ b/src/Cronos/CronExpression.cs @@ -41,6 +41,13 @@ public sealed class CronExpression: IEquatable /// Equals to "0 0 1 1 *". /// public static readonly CronExpression Yearly = Parse("0 0 1 1 *", CronFormat.Standard); + + /// + /// Represents a cron expression that fires at an unspecified time once per year. + /// Equals to "H H H H H *". + /// + public static CronExpression YearlyWithJitter(int jitterSeed) => + Parse("H H H H H *", CronFormat.IncludeSeconds, jitterSeed); /// /// Represents a cron expression that fires every Sunday at midnight. @@ -48,29 +55,64 @@ public sealed class CronExpression: IEquatable /// public static readonly CronExpression Weekly = Parse("0 0 * * 0", CronFormat.Standard); + /// + /// Represents a cron expression that fires at an unspecified time once per week. + /// Equals to "H H H * * H". + /// + public static CronExpression WeeklyWithJitter(int jitterSeed) => + Parse("H H H * * H", CronFormat.IncludeSeconds, jitterSeed); + /// /// Represents a cron expression that fires on 1st day of every month at midnight. /// Equals to "0 0 1 * *". /// public static readonly CronExpression Monthly = Parse("0 0 1 * *", CronFormat.Standard); + /// + /// Represents a cron expression that fires at an unspecified time once per month. + /// Equals to "H H H H * *". + /// + public static CronExpression MonthlyWithJitter(int jitterSeed) => + Parse("H H H H * *", CronFormat.IncludeSeconds, jitterSeed); + /// /// Represents a cron expression that fires every day at midnight. /// Equals to "0 0 * * *". /// public static readonly CronExpression Daily = Parse("0 0 * * *", CronFormat.Standard); + /// + /// Represents a cron expression that fires at an unspecified time every day. + /// Equals to "H H H * * *". + /// + public static CronExpression DailyWithJitter(int jitterSeed) => + Parse("H H H * * *", CronFormat.IncludeSeconds, jitterSeed); + /// /// Represents a cron expression that fires every hour at the beginning of the hour. /// Equals to "0 * * * *". /// public static readonly CronExpression Hourly = Parse("0 * * * *", CronFormat.Standard); - + + /// + /// Represents a cron expression that fires at an unspecified time every hour. + /// Equals to "H H * * * *". + /// + public static CronExpression HourlyWithJitter(int jitterSeed) => + Parse("H H * * * *", CronFormat.IncludeSeconds, jitterSeed); + /// /// Represents a cron expression that fires every minute. /// Equals to "* * * * *". /// public static readonly CronExpression EveryMinute = Parse("* * * * *", CronFormat.Standard); + + /// + /// Represents a cron expression that fires at an unspecified second every minute. + /// Equals to "H * * * * *". + /// + public static CronExpression EveryMinuteWithJitter(int jitterSeed) => + Parse("H * * * * *", CronFormat.IncludeSeconds, jitterSeed); /// /// Represents a cron expression that fires every second. @@ -128,23 +170,22 @@ internal CronExpression( /// /// Constructs a new based on the specified - /// cron expression. It's supported expressions consisting of 5 fields: + /// cron expression. Its supported expressions consist of 5 fields: /// minute, hour, day of month, month, day of week. - /// If you want to parse non-standard cron expressions use with specified CronFields argument. /// See more: https://github.com/HangfireIO/Cronos /// - public static CronExpression Parse(string expression) + public static CronExpression Parse(string expression, int? jitterSeed = null) { - return Parse(expression, CronFormat.Standard); + return Parse(expression, CronFormat.Standard, jitterSeed); } /// /// Constructs a new based on the specified - /// cron expression. It's supported expressions consisting of 5 or 6 fields: + /// cron expression. Its supported expressions consist of 5 or 6 fields: /// second (optional), minute, hour, day of month, month, day of week. /// See more: https://github.com/HangfireIO/Cronos /// - public static CronExpression Parse(string expression, CronFormat format) + public static CronExpression Parse(string expression, CronFormat format, int? jitterSeed = null) { #if NET6_0_OR_GREATER ArgumentNullException.ThrowIfNull(expression); @@ -152,7 +193,7 @@ public static CronExpression Parse(string expression, CronFormat format) if (expression == null) throw new ArgumentNullException(nameof(expression)); #endif - return CronParser.Parse(expression, format); + return CronParser.Parse(expression, format, jitterSeed); } /// diff --git a/src/Cronos/CronField.cs b/src/Cronos/CronField.cs index d35446f..82ac5b8 100644 --- a/src/Cronos/CronField.cs +++ b/src/Cronos/CronField.cs @@ -47,6 +47,7 @@ internal sealed class CronField public static readonly CronField Hours = new CronField("Hours", 0, 23, null, true); public static readonly CronField Minutes = new CronField("Minutes", 0, 59, null, true); public static readonly CronField Seconds = new CronField("Seconds", 0, 59, null, true); + public const int LastCommonDayOfMonth = 28; static CronField() { diff --git a/src/Cronos/CronFormat.cs b/src/Cronos/CronFormat.cs index 994fd72..65de239 100644 --- a/src/Cronos/CronFormat.cs +++ b/src/Cronos/CronFormat.cs @@ -20,14 +20,11 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -using System; - namespace Cronos { /// - /// Defines the cron format options that customize string parsing for . + /// Defines the cron format options that customize string parsing for . /// - [Flags] public enum CronFormat { /// diff --git a/src/Cronos/CronParser.cs b/src/Cronos/CronParser.cs index 1a5dd28..c241eca 100644 --- a/src/Cronos/CronParser.cs +++ b/src/Cronos/CronParser.cs @@ -33,7 +33,7 @@ internal static class CronParser private const int MaxNthDayOfWeek = 5; private const int SundayBits = 0b1000_0001; - public static unsafe CronExpression Parse(string expression, CronFormat format) + public static unsafe CronExpression Parse(string expression, CronFormat format, int? jitterSeed = null) { fixed (char* value = expression) { @@ -43,7 +43,7 @@ public static unsafe CronExpression Parse(string expression, CronFormat format) if (Accept(ref pointer, '@')) { - var cronExpression = ParseMacro(ref pointer); + var cronExpression = ParseMacro(ref pointer, jitterSeed); SkipWhiteSpaces(ref pointer); if (ReferenceEquals(cronExpression, null) || !IsEndOfString(*pointer)) ThrowFormatException("Macro: Unexpected character '{0}' on position {1}.", *pointer, pointer - value); @@ -55,10 +55,12 @@ public static unsafe CronExpression Parse(string expression, CronFormat format) byte lastMonthOffset = default; CronExpressionFlag flags = default; + + Random? rng = jitterSeed == null ? null : new Random(jitterSeed.Value); if (format == CronFormat.IncludeSeconds) { - second = ParseField(CronField.Seconds, ref pointer, ref flags); + second = ParseField(CronField.Seconds, ref pointer, ref flags, rng); ParseWhiteSpace(CronField.Seconds, ref pointer); } else @@ -66,23 +68,22 @@ public static unsafe CronExpression Parse(string expression, CronFormat format) SetBit(ref second, CronField.Seconds.First); } - var minute = ParseField(CronField.Minutes, ref pointer, ref flags); + var minute = ParseField(CronField.Minutes, ref pointer, ref flags, rng); ParseWhiteSpace(CronField.Minutes, ref pointer); - var hour = (uint)ParseField(CronField.Hours, ref pointer, ref flags); + var hour = (uint)ParseField(CronField.Hours, ref pointer, ref flags, rng); ParseWhiteSpace(CronField.Hours, ref pointer); - var dayOfMonth = (uint)ParseDayOfMonth(ref pointer, ref flags, ref lastMonthOffset); - + var dayOfMonth = (uint)ParseDayOfMonth(ref pointer, ref flags, ref lastMonthOffset, rng); ParseWhiteSpace(CronField.DaysOfMonth, ref pointer); - var month = (ushort)ParseField(CronField.Months, ref pointer, ref flags); + var month = (ushort)ParseField(CronField.Months, ref pointer, ref flags, rng); ParseWhiteSpace(CronField.Months, ref pointer); - var dayOfWeek = (byte)ParseDayOfWeek(ref pointer, ref flags, ref nthDayOfWeek); + var dayOfWeek = (byte)ParseDayOfWeek(ref pointer, ref flags, ref nthDayOfWeek, rng); ParseEndOfString(ref pointer); - // Make sundays equivalent. + // Make Sundays equivalent. if ((dayOfWeek & SundayBits) != 0) { dayOfWeek |= SundayBits; @@ -121,7 +122,7 @@ private static unsafe void ParseEndOfString(ref char* pointer) } [SuppressMessage("SonarLint", "S1764:IdenticalExpressionsShouldNotBeUsedOnBothSidesOfOperators", Justification = "Expected, as the AcceptCharacter method produces side effects.")] - private static unsafe CronExpression? ParseMacro(ref char* pointer) + private static unsafe CronExpression? ParseMacro(ref char* pointer, int? jitterSeed) { switch (ToUpper(*pointer++)) { @@ -133,14 +134,18 @@ private static unsafe void ParseEndOfString(ref char* pointer) AcceptCharacter(ref pointer, 'L') && AcceptCharacter(ref pointer, 'L') && AcceptCharacter(ref pointer, 'Y')) - return CronExpression.Yearly; + return jitterSeed == null + ? CronExpression.Yearly + : CronExpression.YearlyWithJitter(jitterSeed.Value); return null; case 'D': if (AcceptCharacter(ref pointer, 'A') && AcceptCharacter(ref pointer, 'I') && AcceptCharacter(ref pointer, 'L') && AcceptCharacter(ref pointer, 'Y')) - return CronExpression.Daily; + return jitterSeed == null + ? CronExpression.Daily + : CronExpression.DailyWithJitter(jitterSeed.Value); return null; case 'E': if (AcceptCharacter(ref pointer, 'V') && @@ -155,7 +160,9 @@ private static unsafe void ParseEndOfString(ref char* pointer) AcceptCharacter(ref pointer, 'U') && AcceptCharacter(ref pointer, 'T') && AcceptCharacter(ref pointer, 'E')) - return CronExpression.EveryMinute; + return jitterSeed == null + ? CronExpression.EveryMinute + : CronExpression.EveryMinuteWithJitter(jitterSeed.Value); if (*(pointer - 1) != '_') return null; @@ -175,7 +182,10 @@ private static unsafe void ParseEndOfString(ref char* pointer) AcceptCharacter(ref pointer, 'R') && AcceptCharacter(ref pointer, 'L') && AcceptCharacter(ref pointer, 'Y')) - return CronExpression.Hourly; + return jitterSeed == null + ? CronExpression.Hourly + : CronExpression.HourlyWithJitter(jitterSeed.Value); + return null; case 'M': if (AcceptCharacter(ref pointer, 'O') && @@ -184,7 +194,9 @@ private static unsafe void ParseEndOfString(ref char* pointer) AcceptCharacter(ref pointer, 'H') && AcceptCharacter(ref pointer, 'L') && AcceptCharacter(ref pointer, 'Y')) - return CronExpression.Monthly; + return jitterSeed == null + ? CronExpression.Monthly + : CronExpression.MonthlyWithJitter(jitterSeed.Value); if (ToUpper(*(pointer - 1)) == 'M' && AcceptCharacter(ref pointer, 'I') && @@ -194,7 +206,9 @@ private static unsafe void ParseEndOfString(ref char* pointer) AcceptCharacter(ref pointer, 'G') && AcceptCharacter(ref pointer, 'H') && AcceptCharacter(ref pointer, 'T')) - return CronExpression.Daily; + return jitterSeed == null + ? CronExpression.Daily + : CronExpression.DailyWithJitter(jitterSeed.Value); return null; case 'W': @@ -203,7 +217,10 @@ private static unsafe void ParseEndOfString(ref char* pointer) AcceptCharacter(ref pointer, 'K') && AcceptCharacter(ref pointer, 'L') && AcceptCharacter(ref pointer, 'Y')) - return CronExpression.Weekly; + return jitterSeed == null + ? CronExpression.Weekly + : CronExpression.WeeklyWithJitter(jitterSeed.Value); + return null; case 'Y': if (AcceptCharacter(ref pointer, 'E') && @@ -211,7 +228,10 @@ private static unsafe void ParseEndOfString(ref char* pointer) AcceptCharacter(ref pointer, 'R') && AcceptCharacter(ref pointer, 'L') && AcceptCharacter(ref pointer, 'Y')) - return CronExpression.Yearly; + return jitterSeed == null + ? CronExpression.Yearly + : CronExpression.YearlyWithJitter(jitterSeed.Value); + return null; default: pointer--; @@ -219,13 +239,19 @@ private static unsafe void ParseEndOfString(ref char* pointer) } } - private static unsafe ulong ParseField(CronField field, ref char* pointer, ref CronExpressionFlag flags) + private static unsafe ulong ParseField(CronField field, ref char* pointer, ref CronExpressionFlag flags, Random? rng) { if (Accept(ref pointer, '*') || Accept(ref pointer, '?')) { if (field.CanDefineInterval) flags |= CronExpressionFlag.Interval; return ParseStar(field, ref pointer); } + + if (Accept(ref pointer, 'H')) + { + if (field.CanDefineInterval) flags |= CronExpressionFlag.Interval; + return ParseHash(field, ref pointer, rng); + } var num = ParseValue(field, ref pointer); @@ -235,11 +261,13 @@ private static unsafe ulong ParseField(CronField field, ref char* pointer, ref C return bits; } - private static unsafe ulong ParseDayOfMonth(ref char* pointer, ref CronExpressionFlag flags, ref byte lastDayOffset) + private static unsafe ulong ParseDayOfMonth(ref char* pointer, ref CronExpressionFlag flags, ref byte lastDayOffset, Random? rng) { var field = CronField.DaysOfMonth; if (Accept(ref pointer, '*') || Accept(ref pointer, '?')) return ParseStar(field, ref pointer); + + if (Accept(ref pointer, 'H')) return ParseHash(field, ref pointer, rng); if (AcceptCharacter(ref pointer, 'L')) return ParseLastDayOfMonth(field, ref pointer, ref flags, ref lastDayOffset); @@ -257,10 +285,12 @@ private static unsafe ulong ParseDayOfMonth(ref char* pointer, ref CronExpressio return bits; } - private static unsafe ulong ParseDayOfWeek(ref char* pointer, ref CronExpressionFlag flags, ref byte nthWeekDay) + private static unsafe ulong ParseDayOfWeek(ref char* pointer, ref CronExpressionFlag flags, ref byte nthWeekDay, Random? rng) { var field = CronField.DaysOfWeek; if (Accept(ref pointer, '*') || Accept(ref pointer, '?')) return ParseStar(field, ref pointer); + + if (Accept(ref pointer, 'H')) return ParseHash(field, ref pointer, rng); var dayOfWeek = ParseValue(field, ref pointer); @@ -280,6 +310,23 @@ private static unsafe ulong ParseStar(CronField field, ref char* pointer) : field.AllBits; } + private static unsafe ulong ParseHash(CronField field, ref char* pointer, Random? rng) + { + if (rng == null) throw new ArgumentNullException(nameof(rng), "Using H in the format requires providing a jitter seed"); + + // Prevent against calculating the 31st of February + var maxValueInclusive = field == CronField.DaysOfMonth + ? CronField.LastCommonDayOfMonth + : field.Last; +#pragma warning disable CA5394 + var jitter = rng.Next(field.First, maxValueInclusive + 1); +#pragma warning restore CA5394 + + return Accept(ref pointer, '/') + ? ParseStep(field, ref pointer, field.First, field.Last, jitter) + : GetBit(jitter); + } + private static unsafe ulong ParseList(CronField field, ref char* pointer, ref CronExpressionFlag flags) { var num = ParseValue(field, ref pointer); @@ -310,14 +357,14 @@ private static unsafe ulong ParseRange(CronField field, ref char* pointer, int l return GetBits(field, low, high, 1); } - private static unsafe ulong ParseStep(CronField field, ref char* pointer, int low, int high) + private static unsafe ulong ParseStep(CronField field, ref char* pointer, int low, int high, int? jitter = null) { // Get the step size -- note: we don't pass the // names here, because the number is not an // element id, it's a step size. 'low' is // sent as a 0 since there is no offset either. var step = ParseNumber(field, ref pointer, 1, field.Last); - return GetBits(field, low, high, step); + return GetBits(field, low, high, step, jitter); } private static unsafe ulong ParseLastDayOfMonth(CronField field, ref char* pointer, ref CronExpressionFlag flags, ref byte lastMonthOffset) @@ -384,28 +431,43 @@ private static unsafe int ParseValue(CronField field, ref char* pointer) return num; } - private static ulong GetBits(CronField field, int num1, int num2, int step) + private static ulong GetBits(CronField field, int num1, int num2, int step, int? jitter = null) { + // a jittered expression can't have an explicit range, so there would be no need to reverse one if (num2 < num1) return GetReversedRangeBits(field, num1, num2, step); if (step == 1) return (1UL << (num2 + 1)) - (1UL << num1); - return GetRangeBits(num1, num2, step); + return GetRangeBits(num1, num2, step, jitter); } - private static ulong GetRangeBits(int low, int high, int step) + private static ulong GetRangeBits(int low, int high, int step, int? jitter = null) { var bits = 0UL; - for (var i = low; i <= high; i += step) + if (jitter.HasValue) { - SetBit(ref bits, i); + // we will wrap around the range with modulus, which breaks the calculations when the ranges are reversed + var range = high - low + 1; + for (var i = low; i <= high; i += step) + { + SetBit(ref bits, (i + jitter.Value) % range); + } } + else + { + for (var i = low; i <= high; i += step) + { + SetBit(ref bits, i); + } + + } + return bits; } private static ulong GetReversedRangeBits(CronField field, int num1, int num2, int step) { var high = field.Last; - // Skip one of sundays. + // Skip one of the Sundays. if (field == CronField.DaysOfWeek) high--; var bits = GetRangeBits(num1, high, step); diff --git a/tests/Cronos.Tests/CronExpressionFacts.cs b/tests/Cronos.Tests/CronExpressionFacts.cs index 7e2e1d4..2ba37e6 100644 --- a/tests/Cronos.Tests/CronExpressionFacts.cs +++ b/tests/Cronos.Tests/CronExpressionFacts.cs @@ -2447,6 +2447,58 @@ public void GetNextOccurrence_ReturnsCorrectDate_When6fieldsExpressionIsUsedAndI Assert.Equal(GetInstantFromLocalTime(expectedString, EasternTimeZone), nextOccurrence); } + [Theory] + + // Basics + [InlineData("H * * * *", 3, "2017-03-23 16:46", "2017-03-23 17:17")] // minute becomes 17 + [InlineData("* H * * *", 3, "2017-03-23 16:46", "2017-03-24 07:00")] // hour becomes 7 + [InlineData("* * H * *", 3, "2017-03-23 16:46", "2017-04-09 00:00")] // day of month becomes 9 + [InlineData("* * * H *", 3, "2017-03-23 16:46", "2017-04-01 00:00")] // month becomes 4 + [InlineData("* * * * H", 3, "2017-03-23 16:46", "2017-03-28 00:00")] // day of week becomes 2/Tuesday + + // With steps + [InlineData("H/30 * * * *", 3, "2017-03-23 16:46", "2017-03-23 16:47")] // minute offset becomes 17, so 17/47 + [InlineData("* H/12 * * *", 3, "2017-03-23 16:46", "2017-03-23 19:00")] // hour offset becomes 7, so 7/19 + [InlineData("* * H/15 * *", 3, "2017-03-23 16:46", "2017-03-25 00:00")] // day of month offset becomes 9, so 10/25 (because low is 1) + [InlineData("* * * H/6 *", 3, "2017-03-23 16:46", "2017-05-01 00:00")] // month offset becomes 4, so 5/11 (because low is 1) + [InlineData("* * * * H/3", 3, "2017-03-23 16:46", "2017-03-24 00:00")] // day of week offset becomes 2, so 2/5 + + public void GetNextOccurrence_ReturnsCorrectDate_WhenExpressionContainsHash(string cronExpression, int hash, string fromString, string expectedString) + { + var expression = CronExpression.Parse(cronExpression, hash); + + var fromInstant = GetInstantFromLocalTime(fromString, EasternTimeZone); + + var occurrence = expression.GetNextOccurrence(fromInstant, EasternTimeZone, inclusive: true); + + Assert.Equal(GetInstantFromLocalTime(expectedString, EasternTimeZone), occurrence); + } + + [Theory] + [InlineData("@every_minute", 3, "2017-03-23 16:46", "2017-03-23 16:46:17")] + [InlineData("@every_minute", 3, "2017-03-23 16:47", "2017-03-23 16:47:17")] // same day/time + [InlineData("@hourly", 3, "2017-03-23 16:46", "2017-03-23 17:41:17")] + [InlineData("@hourly", 3, "2017-03-23 17:40", "2017-03-23 17:41:17")] // same day/time + [InlineData("@daily", 3, "2017-03-23 16:46", "2017-03-23 20:41:17")] + [InlineData("@daily", 3, "2017-03-24 16:46", "2017-03-24 20:41:17")] // same day/time + [InlineData("@weekly", 3, "2017-03-23 16:46", "2017-03-27 20:41:17")] + [InlineData("@weekly", 3, "2017-03-30 16:46", "2017-04-03 20:41:17")] // same day/time + [InlineData("@monthly", 3, "2017-03-23 16:46", "2017-04-06 20:41:17")] + [InlineData("@monthly", 3, "2017-04-23 16:46", "2017-05-06 20:41:17")] // same day/time + [InlineData("@yearly", 3, "2017-03-23 16:46", "2017-07-06 20:41:17")] + [InlineData("@yearly", 3, "2018-03-23 17:40", "2018-07-06 20:41:17")] // same day/time + [InlineData("@annually", 3, "2019-03-23 17:40", "2019-07-06 20:41:17")] // same day/time + public void GetNextOccurrence_ReturnsCorrectDate_WhenMacroExpressionHasJitterSeed(string cronExpression, int hash, string fromString, string expectedString) + { + var expression = CronExpression.Parse(cronExpression, hash); + + var fromInstant = GetInstantFromLocalTime(fromString, EasternTimeZone); + + var occurrence = expression.GetNextOccurrence(fromInstant, EasternTimeZone, inclusive: true); + + Assert.Equal(GetInstantFromLocalTime(expectedString, EasternTimeZone), occurrence); + } + [Fact] public void GetNextOccurrence_FromDateTimeMinValueInclusive_SuccessfullyReturned() { From 6566695ffff479dbdfd75bbece6fd8d964e166b2 Mon Sep 17 00:00:00 2001 From: Justin Williamson Date: Mon, 6 Oct 2025 16:23:50 -0400 Subject: [PATCH 2/8] Convert from passing in a seed to passing in an instance of Random --- README.md | 4 +- src/Cronos/CronExpression.cs | 32 +++++----- src/Cronos/CronFormat.cs | 4 +- src/Cronos/CronParser.cs | 40 ++++++------- tests/Cronos.Tests/CronExpressionFacts.cs | 72 ++++++++++++++--------- 5 files changed, 84 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 18153c5..dc74d9e 100644 --- a/README.md +++ b/README.md @@ -264,11 +264,11 @@ Nov 09, 01:30 +03:00 – run ## Jitter -Cronos supports the ability to distribute cron fields randomly in order to spread out system load over time, a feature called "schedule jitter". You can opt into this capability by passing in a seed for a random number generator and optionally using the special character `H` in a cron expression. Using `H` in an expression while failing to provide a seed will throw an exception. +Cronos supports the ability to distribute cron fields randomly in order to spread out system load over time, a feature called "schedule jitter". You can opt into this capability by passing in a pre-seeded random number generator (RNG) and optionally using the special character `H` in a cron expression. Using `H` in an expression while failing to provide an RNG will throw an exception. Just as it is possible to generate impossible combinations in basic cron expressions (e.g. `* * 31 2 *` being February 31st), care should be taken when combining an `H` with other fields. One common protection is built-in: when `H` is used for the day of the month, the range is limited to the first 28 days of the month. However, expressions like `* * 31 H *` (i.e. the 31st day of a random month) share the same limitations as `* * 31 * *` for months that don't have a 31st day. -The presence of a jitter seed also adjusts the behavior of some macros by offsetting the times by a random amount: +The presence of an RNG also adjusts the behavior of some macros by offsetting the times by a random amount: | Macro | Equivalent | Comment | |-----------------|---------------|---------------------------------------------| diff --git a/src/Cronos/CronExpression.cs b/src/Cronos/CronExpression.cs index dff6747..c6f18e3 100644 --- a/src/Cronos/CronExpression.cs +++ b/src/Cronos/CronExpression.cs @@ -46,8 +46,8 @@ public sealed class CronExpression: IEquatable /// Represents a cron expression that fires at an unspecified time once per year. /// Equals to "H H H H H *". /// - public static CronExpression YearlyWithJitter(int jitterSeed) => - Parse("H H H H H *", CronFormat.IncludeSeconds, jitterSeed); + public static CronExpression YearlyWithJitter(Random rng) => + Parse("H H H H H *", CronFormat.IncludeSeconds, rng); /// /// Represents a cron expression that fires every Sunday at midnight. @@ -59,8 +59,8 @@ public static CronExpression YearlyWithJitter(int jitterSeed) => /// Represents a cron expression that fires at an unspecified time once per week. /// Equals to "H H H * * H". /// - public static CronExpression WeeklyWithJitter(int jitterSeed) => - Parse("H H H * * H", CronFormat.IncludeSeconds, jitterSeed); + public static CronExpression WeeklyWithJitter(Random rng) => + Parse("H H H * * H", CronFormat.IncludeSeconds, rng); /// /// Represents a cron expression that fires on 1st day of every month at midnight. @@ -72,8 +72,8 @@ public static CronExpression WeeklyWithJitter(int jitterSeed) => /// Represents a cron expression that fires at an unspecified time once per month. /// Equals to "H H H H * *". /// - public static CronExpression MonthlyWithJitter(int jitterSeed) => - Parse("H H H H * *", CronFormat.IncludeSeconds, jitterSeed); + public static CronExpression MonthlyWithJitter(Random rng) => + Parse("H H H H * *", CronFormat.IncludeSeconds, rng); /// /// Represents a cron expression that fires every day at midnight. @@ -85,8 +85,8 @@ public static CronExpression MonthlyWithJitter(int jitterSeed) => /// Represents a cron expression that fires at an unspecified time every day. /// Equals to "H H H * * *". /// - public static CronExpression DailyWithJitter(int jitterSeed) => - Parse("H H H * * *", CronFormat.IncludeSeconds, jitterSeed); + public static CronExpression DailyWithJitter(Random rng) => + Parse("H H H * * *", CronFormat.IncludeSeconds, rng); /// /// Represents a cron expression that fires every hour at the beginning of the hour. @@ -98,8 +98,8 @@ public static CronExpression DailyWithJitter(int jitterSeed) => /// Represents a cron expression that fires at an unspecified time every hour. /// Equals to "H H * * * *". /// - public static CronExpression HourlyWithJitter(int jitterSeed) => - Parse("H H * * * *", CronFormat.IncludeSeconds, jitterSeed); + public static CronExpression HourlyWithJitter(Random rng) => + Parse("H H * * * *", CronFormat.IncludeSeconds, rng); /// /// Represents a cron expression that fires every minute. @@ -111,8 +111,8 @@ public static CronExpression HourlyWithJitter(int jitterSeed) => /// Represents a cron expression that fires at an unspecified second every minute. /// Equals to "H * * * * *". /// - public static CronExpression EveryMinuteWithJitter(int jitterSeed) => - Parse("H * * * * *", CronFormat.IncludeSeconds, jitterSeed); + public static CronExpression EveryMinuteWithJitter(Random rng) => + Parse("H * * * * *", CronFormat.IncludeSeconds, rng); /// /// Represents a cron expression that fires every second. @@ -174,9 +174,9 @@ internal CronExpression( /// minute, hour, day of month, month, day of week. /// See more: https://github.com/HangfireIO/Cronos /// - public static CronExpression Parse(string expression, int? jitterSeed = null) + public static CronExpression Parse(string expression, Random? rng = null) { - return Parse(expression, CronFormat.Standard, jitterSeed); + return Parse(expression, CronFormat.Standard, rng); } /// @@ -185,7 +185,7 @@ public static CronExpression Parse(string expression, int? jitterSeed = null) /// second (optional), minute, hour, day of month, month, day of week. /// See more: https://github.com/HangfireIO/Cronos /// - public static CronExpression Parse(string expression, CronFormat format, int? jitterSeed = null) + public static CronExpression Parse(string expression, CronFormat format, Random? rng = null) { #if NET6_0_OR_GREATER ArgumentNullException.ThrowIfNull(expression); @@ -193,7 +193,7 @@ public static CronExpression Parse(string expression, CronFormat format, int? ji if (expression == null) throw new ArgumentNullException(nameof(expression)); #endif - return CronParser.Parse(expression, format, jitterSeed); + return CronParser.Parse(expression, format, rng); } /// diff --git a/src/Cronos/CronFormat.cs b/src/Cronos/CronFormat.cs index 65de239..75d14a4 100644 --- a/src/Cronos/CronFormat.cs +++ b/src/Cronos/CronFormat.cs @@ -20,10 +20,12 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +using System; + namespace Cronos { /// - /// Defines the cron format options that customize string parsing for . + /// Defines the cron format options that customize string parsing for . /// public enum CronFormat { diff --git a/src/Cronos/CronParser.cs b/src/Cronos/CronParser.cs index c241eca..818bfd6 100644 --- a/src/Cronos/CronParser.cs +++ b/src/Cronos/CronParser.cs @@ -33,7 +33,7 @@ internal static class CronParser private const int MaxNthDayOfWeek = 5; private const int SundayBits = 0b1000_0001; - public static unsafe CronExpression Parse(string expression, CronFormat format, int? jitterSeed = null) + public static unsafe CronExpression Parse(string expression, CronFormat format, Random? rng) { fixed (char* value = expression) { @@ -43,7 +43,7 @@ public static unsafe CronExpression Parse(string expression, CronFormat format, if (Accept(ref pointer, '@')) { - var cronExpression = ParseMacro(ref pointer, jitterSeed); + var cronExpression = ParseMacro(ref pointer, rng); SkipWhiteSpaces(ref pointer); if (ReferenceEquals(cronExpression, null) || !IsEndOfString(*pointer)) ThrowFormatException("Macro: Unexpected character '{0}' on position {1}.", *pointer, pointer - value); @@ -56,8 +56,6 @@ public static unsafe CronExpression Parse(string expression, CronFormat format, CronExpressionFlag flags = default; - Random? rng = jitterSeed == null ? null : new Random(jitterSeed.Value); - if (format == CronFormat.IncludeSeconds) { second = ParseField(CronField.Seconds, ref pointer, ref flags, rng); @@ -122,7 +120,7 @@ private static unsafe void ParseEndOfString(ref char* pointer) } [SuppressMessage("SonarLint", "S1764:IdenticalExpressionsShouldNotBeUsedOnBothSidesOfOperators", Justification = "Expected, as the AcceptCharacter method produces side effects.")] - private static unsafe CronExpression? ParseMacro(ref char* pointer, int? jitterSeed) + private static unsafe CronExpression? ParseMacro(ref char* pointer, Random? rng) { switch (ToUpper(*pointer++)) { @@ -134,18 +132,18 @@ private static unsafe void ParseEndOfString(ref char* pointer) AcceptCharacter(ref pointer, 'L') && AcceptCharacter(ref pointer, 'L') && AcceptCharacter(ref pointer, 'Y')) - return jitterSeed == null + return rng == null ? CronExpression.Yearly - : CronExpression.YearlyWithJitter(jitterSeed.Value); + : CronExpression.YearlyWithJitter(rng); return null; case 'D': if (AcceptCharacter(ref pointer, 'A') && AcceptCharacter(ref pointer, 'I') && AcceptCharacter(ref pointer, 'L') && AcceptCharacter(ref pointer, 'Y')) - return jitterSeed == null + return rng == null ? CronExpression.Daily - : CronExpression.DailyWithJitter(jitterSeed.Value); + : CronExpression.DailyWithJitter(rng); return null; case 'E': if (AcceptCharacter(ref pointer, 'V') && @@ -160,9 +158,9 @@ private static unsafe void ParseEndOfString(ref char* pointer) AcceptCharacter(ref pointer, 'U') && AcceptCharacter(ref pointer, 'T') && AcceptCharacter(ref pointer, 'E')) - return jitterSeed == null + return rng == null ? CronExpression.EveryMinute - : CronExpression.EveryMinuteWithJitter(jitterSeed.Value); + : CronExpression.EveryMinuteWithJitter(rng); if (*(pointer - 1) != '_') return null; @@ -182,9 +180,9 @@ private static unsafe void ParseEndOfString(ref char* pointer) AcceptCharacter(ref pointer, 'R') && AcceptCharacter(ref pointer, 'L') && AcceptCharacter(ref pointer, 'Y')) - return jitterSeed == null + return rng == null ? CronExpression.Hourly - : CronExpression.HourlyWithJitter(jitterSeed.Value); + : CronExpression.HourlyWithJitter(rng); return null; case 'M': @@ -194,9 +192,9 @@ private static unsafe void ParseEndOfString(ref char* pointer) AcceptCharacter(ref pointer, 'H') && AcceptCharacter(ref pointer, 'L') && AcceptCharacter(ref pointer, 'Y')) - return jitterSeed == null + return rng == null ? CronExpression.Monthly - : CronExpression.MonthlyWithJitter(jitterSeed.Value); + : CronExpression.MonthlyWithJitter(rng); if (ToUpper(*(pointer - 1)) == 'M' && AcceptCharacter(ref pointer, 'I') && @@ -206,9 +204,9 @@ private static unsafe void ParseEndOfString(ref char* pointer) AcceptCharacter(ref pointer, 'G') && AcceptCharacter(ref pointer, 'H') && AcceptCharacter(ref pointer, 'T')) - return jitterSeed == null + return rng == null ? CronExpression.Daily - : CronExpression.DailyWithJitter(jitterSeed.Value); + : CronExpression.DailyWithJitter(rng); return null; case 'W': @@ -217,9 +215,9 @@ private static unsafe void ParseEndOfString(ref char* pointer) AcceptCharacter(ref pointer, 'K') && AcceptCharacter(ref pointer, 'L') && AcceptCharacter(ref pointer, 'Y')) - return jitterSeed == null + return rng == null ? CronExpression.Weekly - : CronExpression.WeeklyWithJitter(jitterSeed.Value); + : CronExpression.WeeklyWithJitter(rng); return null; case 'Y': @@ -228,9 +226,9 @@ private static unsafe void ParseEndOfString(ref char* pointer) AcceptCharacter(ref pointer, 'R') && AcceptCharacter(ref pointer, 'L') && AcceptCharacter(ref pointer, 'Y')) - return jitterSeed == null + return rng == null ? CronExpression.Yearly - : CronExpression.YearlyWithJitter(jitterSeed.Value); + : CronExpression.YearlyWithJitter(rng); return null; default: diff --git a/tests/Cronos.Tests/CronExpressionFacts.cs b/tests/Cronos.Tests/CronExpressionFacts.cs index 2ba37e6..27cd4a5 100644 --- a/tests/Cronos.Tests/CronExpressionFacts.cs +++ b/tests/Cronos.Tests/CronExpressionFacts.cs @@ -495,6 +495,13 @@ public void Parse_DoesNotThrowAnException_WhenExpressionIsMacro(string cronExpre CronExpression.Parse(cronExpression, format); } + [Fact] + public void Parse_ThrowsAnException_WhenHashIsPresentAndNoRandomIsProvided() + { + var exception = Assert.Throws(() => CronExpression.Parse("H * * * *")); + Assert.Equal("rng", exception.ParamName); + } + [Fact] public void TryParse_ThrowsAnException_WhenExpressionIsNull() { @@ -545,6 +552,13 @@ public void TryParse_WithSecondsSpecified_ReturnsTrue_AndGivesSecondBasedCronExp Assert.Equal(Today.AddSeconds(1), cron!.GetNextOccurrence(Today)); } + [Fact] + public void TryParse_ThrowsAnException_WhenHashIsPresentAndNoRandomIsProvided() + { + var exception = Assert.Throws(() => CronExpression.TryParse("H * * * *", out _)); + Assert.Equal("rng", exception.ParamName); + } + [Theory] [InlineData(DateTimeKind.Unspecified, false)] [InlineData(DateTimeKind.Unspecified, true)] @@ -2450,22 +2464,23 @@ public void GetNextOccurrence_ReturnsCorrectDate_When6fieldsExpressionIsUsedAndI [Theory] // Basics - [InlineData("H * * * *", 3, "2017-03-23 16:46", "2017-03-23 17:17")] // minute becomes 17 - [InlineData("* H * * *", 3, "2017-03-23 16:46", "2017-03-24 07:00")] // hour becomes 7 - [InlineData("* * H * *", 3, "2017-03-23 16:46", "2017-04-09 00:00")] // day of month becomes 9 - [InlineData("* * * H *", 3, "2017-03-23 16:46", "2017-04-01 00:00")] // month becomes 4 - [InlineData("* * * * H", 3, "2017-03-23 16:46", "2017-03-28 00:00")] // day of week becomes 2/Tuesday + [InlineData("H * * * *", "2017-03-23 16:46", "2017-03-23 17:17")] // minute becomes 17 + [InlineData("* H * * *", "2017-03-23 16:46", "2017-03-24 07:00")] // hour becomes 7 + [InlineData("* * H * *", "2017-03-23 16:46", "2017-04-09 00:00")] // day of month becomes 9 + [InlineData("* * * H *", "2017-03-23 16:46", "2017-04-01 00:00")] // month becomes 4 + [InlineData("* * * * H", "2017-03-23 16:46", "2017-03-28 00:00")] // day of week becomes 2/Tuesday // With steps - [InlineData("H/30 * * * *", 3, "2017-03-23 16:46", "2017-03-23 16:47")] // minute offset becomes 17, so 17/47 - [InlineData("* H/12 * * *", 3, "2017-03-23 16:46", "2017-03-23 19:00")] // hour offset becomes 7, so 7/19 - [InlineData("* * H/15 * *", 3, "2017-03-23 16:46", "2017-03-25 00:00")] // day of month offset becomes 9, so 10/25 (because low is 1) - [InlineData("* * * H/6 *", 3, "2017-03-23 16:46", "2017-05-01 00:00")] // month offset becomes 4, so 5/11 (because low is 1) - [InlineData("* * * * H/3", 3, "2017-03-23 16:46", "2017-03-24 00:00")] // day of week offset becomes 2, so 2/5 + [InlineData("H/30 * * * *", "2017-03-23 16:46", "2017-03-23 16:47")] // minute offset becomes 17, so 17/47 + [InlineData("* H/12 * * *", "2017-03-23 16:46", "2017-03-23 19:00")] // hour offset becomes 7, so 7/19 + [InlineData("* * H/15 * *", "2017-03-23 16:46", "2017-03-25 00:00")] // day of month offset becomes 9, so 10/25 (because low is 1) + [InlineData("* * * H/6 *", "2017-03-23 16:46", "2017-05-01 00:00")] // month offset becomes 4, so 5/11 (because low is 1) + [InlineData("* * * * H/3", "2017-03-23 16:46", "2017-03-24 00:00")] // day of week offset becomes 2, so 2/5 - public void GetNextOccurrence_ReturnsCorrectDate_WhenExpressionContainsHash(string cronExpression, int hash, string fromString, string expectedString) + public void GetNextOccurrence_ReturnsCorrectDate_WhenExpressionContainsHash(string cronExpression, string fromString, string expectedString) { - var expression = CronExpression.Parse(cronExpression, hash); + Random rng = new Random(3); + var expression = CronExpression.Parse(cronExpression, rng); var fromInstant = GetInstantFromLocalTime(fromString, EasternTimeZone); @@ -2475,22 +2490,23 @@ public void GetNextOccurrence_ReturnsCorrectDate_WhenExpressionContainsHash(stri } [Theory] - [InlineData("@every_minute", 3, "2017-03-23 16:46", "2017-03-23 16:46:17")] - [InlineData("@every_minute", 3, "2017-03-23 16:47", "2017-03-23 16:47:17")] // same day/time - [InlineData("@hourly", 3, "2017-03-23 16:46", "2017-03-23 17:41:17")] - [InlineData("@hourly", 3, "2017-03-23 17:40", "2017-03-23 17:41:17")] // same day/time - [InlineData("@daily", 3, "2017-03-23 16:46", "2017-03-23 20:41:17")] - [InlineData("@daily", 3, "2017-03-24 16:46", "2017-03-24 20:41:17")] // same day/time - [InlineData("@weekly", 3, "2017-03-23 16:46", "2017-03-27 20:41:17")] - [InlineData("@weekly", 3, "2017-03-30 16:46", "2017-04-03 20:41:17")] // same day/time - [InlineData("@monthly", 3, "2017-03-23 16:46", "2017-04-06 20:41:17")] - [InlineData("@monthly", 3, "2017-04-23 16:46", "2017-05-06 20:41:17")] // same day/time - [InlineData("@yearly", 3, "2017-03-23 16:46", "2017-07-06 20:41:17")] - [InlineData("@yearly", 3, "2018-03-23 17:40", "2018-07-06 20:41:17")] // same day/time - [InlineData("@annually", 3, "2019-03-23 17:40", "2019-07-06 20:41:17")] // same day/time - public void GetNextOccurrence_ReturnsCorrectDate_WhenMacroExpressionHasJitterSeed(string cronExpression, int hash, string fromString, string expectedString) - { - var expression = CronExpression.Parse(cronExpression, hash); + [InlineData("@every_minute", "2017-03-23 16:46", "2017-03-23 16:46:17")] + [InlineData("@every_minute", "2017-03-23 16:47", "2017-03-23 16:47:17")] // same day/time + [InlineData("@hourly", "2017-03-23 16:46", "2017-03-23 17:41:17")] + [InlineData("@hourly", "2017-03-23 17:40", "2017-03-23 17:41:17")] // same day/time + [InlineData("@daily", "2017-03-23 16:46", "2017-03-23 20:41:17")] + [InlineData("@daily", "2017-03-24 16:46", "2017-03-24 20:41:17")] // same day/time + [InlineData("@weekly", "2017-03-23 16:46", "2017-03-27 20:41:17")] + [InlineData("@weekly", "2017-03-30 16:46", "2017-04-03 20:41:17")] // same day/time + [InlineData("@monthly", "2017-03-23 16:46", "2017-04-06 20:41:17")] + [InlineData("@monthly", "2017-04-23 16:46", "2017-05-06 20:41:17")] // same day/time + [InlineData("@yearly", "2017-03-23 16:46", "2017-07-06 20:41:17")] + [InlineData("@yearly", "2018-03-23 17:40", "2018-07-06 20:41:17")] // same day/time + [InlineData("@annually", "2019-03-23 17:40", "2019-07-06 20:41:17")] // same day/time + public void GetNextOccurrence_ReturnsCorrectDate_WhenMacroExpressionHasRandom(string cronExpression, string fromString, string expectedString) + { + Random rng = new Random(3); + var expression = CronExpression.Parse(cronExpression, rng); var fromInstant = GetInstantFromLocalTime(fromString, EasternTimeZone); From 47ec7641d23926426ef722cdf639f5584dc387d8 Mon Sep 17 00:00:00 2001 From: Justin Williamson Date: Sat, 11 Oct 2025 15:19:21 -0400 Subject: [PATCH 3/8] Revert "Convert from passing in a seed to passing in an instance of Random" This reverts commit 6566695ffff479dbdfd75bbece6fd8d964e166b2. --- README.md | 4 +- src/Cronos/CronExpression.cs | 32 +++++----- src/Cronos/CronFormat.cs | 4 +- src/Cronos/CronParser.cs | 40 +++++++------ tests/Cronos.Tests/CronExpressionFacts.cs | 72 +++++++++-------------- 5 files changed, 68 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index dc74d9e..18153c5 100644 --- a/README.md +++ b/README.md @@ -264,11 +264,11 @@ Nov 09, 01:30 +03:00 – run ## Jitter -Cronos supports the ability to distribute cron fields randomly in order to spread out system load over time, a feature called "schedule jitter". You can opt into this capability by passing in a pre-seeded random number generator (RNG) and optionally using the special character `H` in a cron expression. Using `H` in an expression while failing to provide an RNG will throw an exception. +Cronos supports the ability to distribute cron fields randomly in order to spread out system load over time, a feature called "schedule jitter". You can opt into this capability by passing in a seed for a random number generator and optionally using the special character `H` in a cron expression. Using `H` in an expression while failing to provide a seed will throw an exception. Just as it is possible to generate impossible combinations in basic cron expressions (e.g. `* * 31 2 *` being February 31st), care should be taken when combining an `H` with other fields. One common protection is built-in: when `H` is used for the day of the month, the range is limited to the first 28 days of the month. However, expressions like `* * 31 H *` (i.e. the 31st day of a random month) share the same limitations as `* * 31 * *` for months that don't have a 31st day. -The presence of an RNG also adjusts the behavior of some macros by offsetting the times by a random amount: +The presence of a jitter seed also adjusts the behavior of some macros by offsetting the times by a random amount: | Macro | Equivalent | Comment | |-----------------|---------------|---------------------------------------------| diff --git a/src/Cronos/CronExpression.cs b/src/Cronos/CronExpression.cs index c6f18e3..dff6747 100644 --- a/src/Cronos/CronExpression.cs +++ b/src/Cronos/CronExpression.cs @@ -46,8 +46,8 @@ public sealed class CronExpression: IEquatable /// Represents a cron expression that fires at an unspecified time once per year. /// Equals to "H H H H H *". /// - public static CronExpression YearlyWithJitter(Random rng) => - Parse("H H H H H *", CronFormat.IncludeSeconds, rng); + public static CronExpression YearlyWithJitter(int jitterSeed) => + Parse("H H H H H *", CronFormat.IncludeSeconds, jitterSeed); /// /// Represents a cron expression that fires every Sunday at midnight. @@ -59,8 +59,8 @@ public static CronExpression YearlyWithJitter(Random rng) => /// Represents a cron expression that fires at an unspecified time once per week. /// Equals to "H H H * * H". /// - public static CronExpression WeeklyWithJitter(Random rng) => - Parse("H H H * * H", CronFormat.IncludeSeconds, rng); + public static CronExpression WeeklyWithJitter(int jitterSeed) => + Parse("H H H * * H", CronFormat.IncludeSeconds, jitterSeed); /// /// Represents a cron expression that fires on 1st day of every month at midnight. @@ -72,8 +72,8 @@ public static CronExpression WeeklyWithJitter(Random rng) => /// Represents a cron expression that fires at an unspecified time once per month. /// Equals to "H H H H * *". /// - public static CronExpression MonthlyWithJitter(Random rng) => - Parse("H H H H * *", CronFormat.IncludeSeconds, rng); + public static CronExpression MonthlyWithJitter(int jitterSeed) => + Parse("H H H H * *", CronFormat.IncludeSeconds, jitterSeed); /// /// Represents a cron expression that fires every day at midnight. @@ -85,8 +85,8 @@ public static CronExpression MonthlyWithJitter(Random rng) => /// Represents a cron expression that fires at an unspecified time every day. /// Equals to "H H H * * *". /// - public static CronExpression DailyWithJitter(Random rng) => - Parse("H H H * * *", CronFormat.IncludeSeconds, rng); + public static CronExpression DailyWithJitter(int jitterSeed) => + Parse("H H H * * *", CronFormat.IncludeSeconds, jitterSeed); /// /// Represents a cron expression that fires every hour at the beginning of the hour. @@ -98,8 +98,8 @@ public static CronExpression DailyWithJitter(Random rng) => /// Represents a cron expression that fires at an unspecified time every hour. /// Equals to "H H * * * *". /// - public static CronExpression HourlyWithJitter(Random rng) => - Parse("H H * * * *", CronFormat.IncludeSeconds, rng); + public static CronExpression HourlyWithJitter(int jitterSeed) => + Parse("H H * * * *", CronFormat.IncludeSeconds, jitterSeed); /// /// Represents a cron expression that fires every minute. @@ -111,8 +111,8 @@ public static CronExpression HourlyWithJitter(Random rng) => /// Represents a cron expression that fires at an unspecified second every minute. /// Equals to "H * * * * *". /// - public static CronExpression EveryMinuteWithJitter(Random rng) => - Parse("H * * * * *", CronFormat.IncludeSeconds, rng); + public static CronExpression EveryMinuteWithJitter(int jitterSeed) => + Parse("H * * * * *", CronFormat.IncludeSeconds, jitterSeed); /// /// Represents a cron expression that fires every second. @@ -174,9 +174,9 @@ internal CronExpression( /// minute, hour, day of month, month, day of week. /// See more: https://github.com/HangfireIO/Cronos /// - public static CronExpression Parse(string expression, Random? rng = null) + public static CronExpression Parse(string expression, int? jitterSeed = null) { - return Parse(expression, CronFormat.Standard, rng); + return Parse(expression, CronFormat.Standard, jitterSeed); } /// @@ -185,7 +185,7 @@ public static CronExpression Parse(string expression, Random? rng = null) /// second (optional), minute, hour, day of month, month, day of week. /// See more: https://github.com/HangfireIO/Cronos /// - public static CronExpression Parse(string expression, CronFormat format, Random? rng = null) + public static CronExpression Parse(string expression, CronFormat format, int? jitterSeed = null) { #if NET6_0_OR_GREATER ArgumentNullException.ThrowIfNull(expression); @@ -193,7 +193,7 @@ public static CronExpression Parse(string expression, CronFormat format, Random? if (expression == null) throw new ArgumentNullException(nameof(expression)); #endif - return CronParser.Parse(expression, format, rng); + return CronParser.Parse(expression, format, jitterSeed); } /// diff --git a/src/Cronos/CronFormat.cs b/src/Cronos/CronFormat.cs index 75d14a4..65de239 100644 --- a/src/Cronos/CronFormat.cs +++ b/src/Cronos/CronFormat.cs @@ -20,12 +20,10 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -using System; - namespace Cronos { /// - /// Defines the cron format options that customize string parsing for . + /// Defines the cron format options that customize string parsing for . /// public enum CronFormat { diff --git a/src/Cronos/CronParser.cs b/src/Cronos/CronParser.cs index 818bfd6..c241eca 100644 --- a/src/Cronos/CronParser.cs +++ b/src/Cronos/CronParser.cs @@ -33,7 +33,7 @@ internal static class CronParser private const int MaxNthDayOfWeek = 5; private const int SundayBits = 0b1000_0001; - public static unsafe CronExpression Parse(string expression, CronFormat format, Random? rng) + public static unsafe CronExpression Parse(string expression, CronFormat format, int? jitterSeed = null) { fixed (char* value = expression) { @@ -43,7 +43,7 @@ public static unsafe CronExpression Parse(string expression, CronFormat format, if (Accept(ref pointer, '@')) { - var cronExpression = ParseMacro(ref pointer, rng); + var cronExpression = ParseMacro(ref pointer, jitterSeed); SkipWhiteSpaces(ref pointer); if (ReferenceEquals(cronExpression, null) || !IsEndOfString(*pointer)) ThrowFormatException("Macro: Unexpected character '{0}' on position {1}.", *pointer, pointer - value); @@ -56,6 +56,8 @@ public static unsafe CronExpression Parse(string expression, CronFormat format, CronExpressionFlag flags = default; + Random? rng = jitterSeed == null ? null : new Random(jitterSeed.Value); + if (format == CronFormat.IncludeSeconds) { second = ParseField(CronField.Seconds, ref pointer, ref flags, rng); @@ -120,7 +122,7 @@ private static unsafe void ParseEndOfString(ref char* pointer) } [SuppressMessage("SonarLint", "S1764:IdenticalExpressionsShouldNotBeUsedOnBothSidesOfOperators", Justification = "Expected, as the AcceptCharacter method produces side effects.")] - private static unsafe CronExpression? ParseMacro(ref char* pointer, Random? rng) + private static unsafe CronExpression? ParseMacro(ref char* pointer, int? jitterSeed) { switch (ToUpper(*pointer++)) { @@ -132,18 +134,18 @@ private static unsafe void ParseEndOfString(ref char* pointer) AcceptCharacter(ref pointer, 'L') && AcceptCharacter(ref pointer, 'L') && AcceptCharacter(ref pointer, 'Y')) - return rng == null + return jitterSeed == null ? CronExpression.Yearly - : CronExpression.YearlyWithJitter(rng); + : CronExpression.YearlyWithJitter(jitterSeed.Value); return null; case 'D': if (AcceptCharacter(ref pointer, 'A') && AcceptCharacter(ref pointer, 'I') && AcceptCharacter(ref pointer, 'L') && AcceptCharacter(ref pointer, 'Y')) - return rng == null + return jitterSeed == null ? CronExpression.Daily - : CronExpression.DailyWithJitter(rng); + : CronExpression.DailyWithJitter(jitterSeed.Value); return null; case 'E': if (AcceptCharacter(ref pointer, 'V') && @@ -158,9 +160,9 @@ private static unsafe void ParseEndOfString(ref char* pointer) AcceptCharacter(ref pointer, 'U') && AcceptCharacter(ref pointer, 'T') && AcceptCharacter(ref pointer, 'E')) - return rng == null + return jitterSeed == null ? CronExpression.EveryMinute - : CronExpression.EveryMinuteWithJitter(rng); + : CronExpression.EveryMinuteWithJitter(jitterSeed.Value); if (*(pointer - 1) != '_') return null; @@ -180,9 +182,9 @@ private static unsafe void ParseEndOfString(ref char* pointer) AcceptCharacter(ref pointer, 'R') && AcceptCharacter(ref pointer, 'L') && AcceptCharacter(ref pointer, 'Y')) - return rng == null + return jitterSeed == null ? CronExpression.Hourly - : CronExpression.HourlyWithJitter(rng); + : CronExpression.HourlyWithJitter(jitterSeed.Value); return null; case 'M': @@ -192,9 +194,9 @@ private static unsafe void ParseEndOfString(ref char* pointer) AcceptCharacter(ref pointer, 'H') && AcceptCharacter(ref pointer, 'L') && AcceptCharacter(ref pointer, 'Y')) - return rng == null + return jitterSeed == null ? CronExpression.Monthly - : CronExpression.MonthlyWithJitter(rng); + : CronExpression.MonthlyWithJitter(jitterSeed.Value); if (ToUpper(*(pointer - 1)) == 'M' && AcceptCharacter(ref pointer, 'I') && @@ -204,9 +206,9 @@ private static unsafe void ParseEndOfString(ref char* pointer) AcceptCharacter(ref pointer, 'G') && AcceptCharacter(ref pointer, 'H') && AcceptCharacter(ref pointer, 'T')) - return rng == null + return jitterSeed == null ? CronExpression.Daily - : CronExpression.DailyWithJitter(rng); + : CronExpression.DailyWithJitter(jitterSeed.Value); return null; case 'W': @@ -215,9 +217,9 @@ private static unsafe void ParseEndOfString(ref char* pointer) AcceptCharacter(ref pointer, 'K') && AcceptCharacter(ref pointer, 'L') && AcceptCharacter(ref pointer, 'Y')) - return rng == null + return jitterSeed == null ? CronExpression.Weekly - : CronExpression.WeeklyWithJitter(rng); + : CronExpression.WeeklyWithJitter(jitterSeed.Value); return null; case 'Y': @@ -226,9 +228,9 @@ private static unsafe void ParseEndOfString(ref char* pointer) AcceptCharacter(ref pointer, 'R') && AcceptCharacter(ref pointer, 'L') && AcceptCharacter(ref pointer, 'Y')) - return rng == null + return jitterSeed == null ? CronExpression.Yearly - : CronExpression.YearlyWithJitter(rng); + : CronExpression.YearlyWithJitter(jitterSeed.Value); return null; default: diff --git a/tests/Cronos.Tests/CronExpressionFacts.cs b/tests/Cronos.Tests/CronExpressionFacts.cs index 27cd4a5..2ba37e6 100644 --- a/tests/Cronos.Tests/CronExpressionFacts.cs +++ b/tests/Cronos.Tests/CronExpressionFacts.cs @@ -495,13 +495,6 @@ public void Parse_DoesNotThrowAnException_WhenExpressionIsMacro(string cronExpre CronExpression.Parse(cronExpression, format); } - [Fact] - public void Parse_ThrowsAnException_WhenHashIsPresentAndNoRandomIsProvided() - { - var exception = Assert.Throws(() => CronExpression.Parse("H * * * *")); - Assert.Equal("rng", exception.ParamName); - } - [Fact] public void TryParse_ThrowsAnException_WhenExpressionIsNull() { @@ -552,13 +545,6 @@ public void TryParse_WithSecondsSpecified_ReturnsTrue_AndGivesSecondBasedCronExp Assert.Equal(Today.AddSeconds(1), cron!.GetNextOccurrence(Today)); } - [Fact] - public void TryParse_ThrowsAnException_WhenHashIsPresentAndNoRandomIsProvided() - { - var exception = Assert.Throws(() => CronExpression.TryParse("H * * * *", out _)); - Assert.Equal("rng", exception.ParamName); - } - [Theory] [InlineData(DateTimeKind.Unspecified, false)] [InlineData(DateTimeKind.Unspecified, true)] @@ -2464,23 +2450,22 @@ public void GetNextOccurrence_ReturnsCorrectDate_When6fieldsExpressionIsUsedAndI [Theory] // Basics - [InlineData("H * * * *", "2017-03-23 16:46", "2017-03-23 17:17")] // minute becomes 17 - [InlineData("* H * * *", "2017-03-23 16:46", "2017-03-24 07:00")] // hour becomes 7 - [InlineData("* * H * *", "2017-03-23 16:46", "2017-04-09 00:00")] // day of month becomes 9 - [InlineData("* * * H *", "2017-03-23 16:46", "2017-04-01 00:00")] // month becomes 4 - [InlineData("* * * * H", "2017-03-23 16:46", "2017-03-28 00:00")] // day of week becomes 2/Tuesday + [InlineData("H * * * *", 3, "2017-03-23 16:46", "2017-03-23 17:17")] // minute becomes 17 + [InlineData("* H * * *", 3, "2017-03-23 16:46", "2017-03-24 07:00")] // hour becomes 7 + [InlineData("* * H * *", 3, "2017-03-23 16:46", "2017-04-09 00:00")] // day of month becomes 9 + [InlineData("* * * H *", 3, "2017-03-23 16:46", "2017-04-01 00:00")] // month becomes 4 + [InlineData("* * * * H", 3, "2017-03-23 16:46", "2017-03-28 00:00")] // day of week becomes 2/Tuesday // With steps - [InlineData("H/30 * * * *", "2017-03-23 16:46", "2017-03-23 16:47")] // minute offset becomes 17, so 17/47 - [InlineData("* H/12 * * *", "2017-03-23 16:46", "2017-03-23 19:00")] // hour offset becomes 7, so 7/19 - [InlineData("* * H/15 * *", "2017-03-23 16:46", "2017-03-25 00:00")] // day of month offset becomes 9, so 10/25 (because low is 1) - [InlineData("* * * H/6 *", "2017-03-23 16:46", "2017-05-01 00:00")] // month offset becomes 4, so 5/11 (because low is 1) - [InlineData("* * * * H/3", "2017-03-23 16:46", "2017-03-24 00:00")] // day of week offset becomes 2, so 2/5 + [InlineData("H/30 * * * *", 3, "2017-03-23 16:46", "2017-03-23 16:47")] // minute offset becomes 17, so 17/47 + [InlineData("* H/12 * * *", 3, "2017-03-23 16:46", "2017-03-23 19:00")] // hour offset becomes 7, so 7/19 + [InlineData("* * H/15 * *", 3, "2017-03-23 16:46", "2017-03-25 00:00")] // day of month offset becomes 9, so 10/25 (because low is 1) + [InlineData("* * * H/6 *", 3, "2017-03-23 16:46", "2017-05-01 00:00")] // month offset becomes 4, so 5/11 (because low is 1) + [InlineData("* * * * H/3", 3, "2017-03-23 16:46", "2017-03-24 00:00")] // day of week offset becomes 2, so 2/5 - public void GetNextOccurrence_ReturnsCorrectDate_WhenExpressionContainsHash(string cronExpression, string fromString, string expectedString) + public void GetNextOccurrence_ReturnsCorrectDate_WhenExpressionContainsHash(string cronExpression, int hash, string fromString, string expectedString) { - Random rng = new Random(3); - var expression = CronExpression.Parse(cronExpression, rng); + var expression = CronExpression.Parse(cronExpression, hash); var fromInstant = GetInstantFromLocalTime(fromString, EasternTimeZone); @@ -2490,23 +2475,22 @@ public void GetNextOccurrence_ReturnsCorrectDate_WhenExpressionContainsHash(stri } [Theory] - [InlineData("@every_minute", "2017-03-23 16:46", "2017-03-23 16:46:17")] - [InlineData("@every_minute", "2017-03-23 16:47", "2017-03-23 16:47:17")] // same day/time - [InlineData("@hourly", "2017-03-23 16:46", "2017-03-23 17:41:17")] - [InlineData("@hourly", "2017-03-23 17:40", "2017-03-23 17:41:17")] // same day/time - [InlineData("@daily", "2017-03-23 16:46", "2017-03-23 20:41:17")] - [InlineData("@daily", "2017-03-24 16:46", "2017-03-24 20:41:17")] // same day/time - [InlineData("@weekly", "2017-03-23 16:46", "2017-03-27 20:41:17")] - [InlineData("@weekly", "2017-03-30 16:46", "2017-04-03 20:41:17")] // same day/time - [InlineData("@monthly", "2017-03-23 16:46", "2017-04-06 20:41:17")] - [InlineData("@monthly", "2017-04-23 16:46", "2017-05-06 20:41:17")] // same day/time - [InlineData("@yearly", "2017-03-23 16:46", "2017-07-06 20:41:17")] - [InlineData("@yearly", "2018-03-23 17:40", "2018-07-06 20:41:17")] // same day/time - [InlineData("@annually", "2019-03-23 17:40", "2019-07-06 20:41:17")] // same day/time - public void GetNextOccurrence_ReturnsCorrectDate_WhenMacroExpressionHasRandom(string cronExpression, string fromString, string expectedString) - { - Random rng = new Random(3); - var expression = CronExpression.Parse(cronExpression, rng); + [InlineData("@every_minute", 3, "2017-03-23 16:46", "2017-03-23 16:46:17")] + [InlineData("@every_minute", 3, "2017-03-23 16:47", "2017-03-23 16:47:17")] // same day/time + [InlineData("@hourly", 3, "2017-03-23 16:46", "2017-03-23 17:41:17")] + [InlineData("@hourly", 3, "2017-03-23 17:40", "2017-03-23 17:41:17")] // same day/time + [InlineData("@daily", 3, "2017-03-23 16:46", "2017-03-23 20:41:17")] + [InlineData("@daily", 3, "2017-03-24 16:46", "2017-03-24 20:41:17")] // same day/time + [InlineData("@weekly", 3, "2017-03-23 16:46", "2017-03-27 20:41:17")] + [InlineData("@weekly", 3, "2017-03-30 16:46", "2017-04-03 20:41:17")] // same day/time + [InlineData("@monthly", 3, "2017-03-23 16:46", "2017-04-06 20:41:17")] + [InlineData("@monthly", 3, "2017-04-23 16:46", "2017-05-06 20:41:17")] // same day/time + [InlineData("@yearly", 3, "2017-03-23 16:46", "2017-07-06 20:41:17")] + [InlineData("@yearly", 3, "2018-03-23 17:40", "2018-07-06 20:41:17")] // same day/time + [InlineData("@annually", 3, "2019-03-23 17:40", "2019-07-06 20:41:17")] // same day/time + public void GetNextOccurrence_ReturnsCorrectDate_WhenMacroExpressionHasJitterSeed(string cronExpression, int hash, string fromString, string expectedString) + { + var expression = CronExpression.Parse(cronExpression, hash); var fromInstant = GetInstantFromLocalTime(fromString, EasternTimeZone); From 220318411e58cb7f0523b825d7a3dcedef0ffc9d Mon Sep 17 00:00:00 2001 From: Justin Williamson Date: Sat, 11 Oct 2025 22:13:50 -0400 Subject: [PATCH 4/8] Avoid modulus by generating jitter within the step range --- src/Cronos/CronFormat.cs | 3 ++ src/Cronos/CronParser.cs | 56 ++++++++++------------- tests/Cronos.Tests/CronExpressionFacts.cs | 29 ++++++++++-- 3 files changed, 52 insertions(+), 36 deletions(-) diff --git a/src/Cronos/CronFormat.cs b/src/Cronos/CronFormat.cs index 65de239..ab13511 100644 --- a/src/Cronos/CronFormat.cs +++ b/src/Cronos/CronFormat.cs @@ -20,11 +20,14 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +using System; + namespace Cronos { /// /// Defines the cron format options that customize string parsing for . /// + [Flags] public enum CronFormat { /// diff --git a/src/Cronos/CronParser.cs b/src/Cronos/CronParser.cs index c241eca..34f6df6 100644 --- a/src/Cronos/CronParser.cs +++ b/src/Cronos/CronParser.cs @@ -310,21 +310,21 @@ private static unsafe ulong ParseStar(CronField field, ref char* pointer) : field.AllBits; } + [SuppressMessage("Security", "CA5394:Do not use insecure randomness")] private static unsafe ulong ParseHash(CronField field, ref char* pointer, Random? rng) { + // prior to this point in parsing, it has been valid to not pass in a jitter seed. if (rng == null) throw new ArgumentNullException(nameof(rng), "Using H in the format requires providing a jitter seed"); // Prevent against calculating the 31st of February var maxValueInclusive = field == CronField.DaysOfMonth ? CronField.LastCommonDayOfMonth : field.Last; -#pragma warning disable CA5394 - var jitter = rng.Next(field.First, maxValueInclusive + 1); -#pragma warning restore CA5394 - return Accept(ref pointer, '/') - ? ParseStep(field, ref pointer, field.First, field.Last, jitter) - : GetBit(jitter); + if (Accept(ref pointer, '/')) return ParseHashStep(field, ref pointer, field.First, maxValueInclusive, rng); + + var jitter = rng.Next(field.First, maxValueInclusive + 1); + return GetBit(jitter); } private static unsafe ulong ParseList(CronField field, ref char* pointer, ref CronExpressionFlag flags) @@ -357,14 +357,22 @@ private static unsafe ulong ParseRange(CronField field, ref char* pointer, int l return GetBits(field, low, high, 1); } - private static unsafe ulong ParseStep(CronField field, ref char* pointer, int low, int high, int? jitter = null) + private static unsafe ulong ParseStep(CronField field, ref char* pointer, int low, int high) { - // Get the step size -- note: we don't pass the - // names here, because the number is not an - // element id, it's a step size. 'low' is - // sent as a 0 since there is no offset either. var step = ParseNumber(field, ref pointer, 1, field.Last); - return GetBits(field, low, high, step, jitter); + return GetBits(field, low, high, step); + } + + [SuppressMessage("Security", "CA5394:Do not use insecure randomness")] + private static unsafe ulong ParseHashStep(CronField field, ref char* pointer, int low, int high, Random rng) + { + // field range may have been truncated, e.g. day of month + var step = ParseNumber(field, ref pointer, 1, high); + + // rather than generate an offset somewhere in the field's range, we'll instead generate an offset in the + // step range, allowing us to avoid a modulus operation when determining the bits + var jitter = rng.Next(0, step); + return GetBits(field, low + jitter, high, step); } private static unsafe ulong ParseLastDayOfMonth(CronField field, ref char* pointer, ref CronExpressionFlag flags, ref byte lastMonthOffset) @@ -431,34 +439,20 @@ private static unsafe int ParseValue(CronField field, ref char* pointer) return num; } - private static ulong GetBits(CronField field, int num1, int num2, int step, int? jitter = null) + private static ulong GetBits(CronField field, int num1, int num2, int step) { - // a jittered expression can't have an explicit range, so there would be no need to reverse one if (num2 < num1) return GetReversedRangeBits(field, num1, num2, step); if (step == 1) return (1UL << (num2 + 1)) - (1UL << num1); - return GetRangeBits(num1, num2, step, jitter); + return GetRangeBits(num1, num2, step); } - private static ulong GetRangeBits(int low, int high, int step, int? jitter = null) + private static ulong GetRangeBits(int low, int high, int step) { var bits = 0UL; - if (jitter.HasValue) + for (var i = low; i <= high; i += step) { - // we will wrap around the range with modulus, which breaks the calculations when the ranges are reversed - var range = high - low + 1; - for (var i = low; i <= high; i += step) - { - SetBit(ref bits, (i + jitter.Value) % range); - } - } - else - { - for (var i = low; i <= high; i += step) - { - SetBit(ref bits, i); - } - + SetBit(ref bits, i); } return bits; diff --git a/tests/Cronos.Tests/CronExpressionFacts.cs b/tests/Cronos.Tests/CronExpressionFacts.cs index 2ba37e6..9f0821a 100644 --- a/tests/Cronos.Tests/CronExpressionFacts.cs +++ b/tests/Cronos.Tests/CronExpressionFacts.cs @@ -494,6 +494,14 @@ public void Parse_DoesNotThrowAnException_WhenExpressionIsMacro(string cronExpre { CronExpression.Parse(cronExpression, format); } + + [Fact] + public void Parse_ThrowsAnException_WhenHashIsPresentAndNoRandomIsProvided() + { + var exception = Assert.Throws(() => CronExpression.Parse("H * * * *")); + Assert.Equal("rng", exception.ParamName); + Assert.StartsWith("Using H in the format requires providing a jitter seed", exception.Message); + } [Fact] public void TryParse_ThrowsAnException_WhenExpressionIsNull() @@ -545,6 +553,14 @@ public void TryParse_WithSecondsSpecified_ReturnsTrue_AndGivesSecondBasedCronExp Assert.Equal(Today.AddSeconds(1), cron!.GetNextOccurrence(Today)); } + [Fact] + public void TryParse_ThrowsAnException_WhenHashIsPresentAndNoRandomIsProvided() + { + var exception = Assert.Throws(() => CronExpression.TryParse("H * * * *", out _)); + Assert.Equal("rng", exception.ParamName); + Assert.StartsWith("Using H in the format requires providing a jitter seed", exception.Message); + } + [Theory] [InlineData(DateTimeKind.Unspecified, false)] [InlineData(DateTimeKind.Unspecified, true)] @@ -2457,12 +2473,15 @@ public void GetNextOccurrence_ReturnsCorrectDate_When6fieldsExpressionIsUsedAndI [InlineData("* * * * H", 3, "2017-03-23 16:46", "2017-03-28 00:00")] // day of week becomes 2/Tuesday // With steps - [InlineData("H/30 * * * *", 3, "2017-03-23 16:46", "2017-03-23 16:47")] // minute offset becomes 17, so 17/47 - [InlineData("* H/12 * * *", 3, "2017-03-23 16:46", "2017-03-23 19:00")] // hour offset becomes 7, so 7/19 - [InlineData("* * H/15 * *", 3, "2017-03-23 16:46", "2017-03-25 00:00")] // day of month offset becomes 9, so 10/25 (because low is 1) - [InlineData("* * * H/6 *", 3, "2017-03-23 16:46", "2017-05-01 00:00")] // month offset becomes 4, so 5/11 (because low is 1) - [InlineData("* * * * H/3", 3, "2017-03-23 16:46", "2017-03-24 00:00")] // day of week offset becomes 2, so 2/5 + [InlineData("H/30 * * * *", 3, "2017-03-23 16:46", "2017-03-23 17:08")] // minute offset becomes 8, so 8/38 + [InlineData("* H/12 * * *", 3, "2017-03-23 16:46", "2017-03-24 03:00")] // hour offset becomes 3, so 3/15 + [InlineData("* * H/15 * *", 3, "2017-03-23 16:46", "2017-04-05 00:00")] // day of month offset becomes 4, so 5/20 (because low is 1) + [InlineData("* * * H/6 *", 3, "2017-03-23 16:46", "2017-08-01 00:00")] // month offset becomes 1, so 2/8 (because low is 1) + [InlineData("* * * * H/3", 4, "2017-03-23 16:46", "2017-03-24 00:00")] // day of week offset becomes 2, so 2/5 + // 1-based fields where the stepped hash lands on the last value in the range + [InlineData("* * H/3 * *", 3, "2017-03-27 16:46", "2017-03-28 00:00")] // day of month offset becomes 9, so 10/28 + [InlineData("* * * H/3 *", 4, "2017-10-23 16:46", "2017-12-01 00:00")] // month offset becomes 2, so 3/6/9/12 public void GetNextOccurrence_ReturnsCorrectDate_WhenExpressionContainsHash(string cronExpression, int hash, string fromString, string expectedString) { var expression = CronExpression.Parse(cronExpression, hash); From 67d905e6f829969620cc780682584f09bd7aee14 Mon Sep 17 00:00:00 2001 From: Justin Williamson Date: Sat, 11 Oct 2025 22:34:41 -0400 Subject: [PATCH 5/8] Offer API examples --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index 18153c5..8e2bc35 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,24 @@ Nov 09, 01:30 +03:00 – run Cronos supports the ability to distribute cron fields randomly in order to spread out system load over time, a feature called "schedule jitter". You can opt into this capability by passing in a seed for a random number generator and optionally using the special character `H` in a cron expression. Using `H` in an expression while failing to provide a seed will throw an exception. +Example API usages: + +```csharp +// if you're in a job-schedulig scenario, you would probably want to +// use information from the job itself as a seed, but this can be anything +var seed = jobId.GetHashCode(); + +// note that each of these 3 expressions are equivalent +var hourlyExpressionWithoutJitter = CronExpression.Parse("0 * * * *"); +var hourlyMacroWithoutJitter = CronExpression.Parse("@hourly"); +var hourlyNamedWithoutJitter = CronExpression.Hourly; + +// and each of these are equivalent because the same seed was used +var hourlyExpressionWithJitter = CronExpression.Parse("H H * * * *", CronFormat.IncludeSeconds, seed); +var hourlyMacroWithJitter = CronExpression.Parse("@hourly", seed); +var hourlyNamedWithJitter = CronExpression.HourlyWithJitter(seed); +``` + Just as it is possible to generate impossible combinations in basic cron expressions (e.g. `* * 31 2 *` being February 31st), care should be taken when combining an `H` with other fields. One common protection is built-in: when `H` is used for the day of the month, the range is limited to the first 28 days of the month. However, expressions like `* * 31 H *` (i.e. the 31st day of a random month) share the same limitations as `* * 31 * *` for months that don't have a 31st day. The presence of a jitter seed also adjusts the behavior of some macros by offsetting the times by a random amount: From 7cca7d10597d49bafd9f52df7fd6b50a0d9e3441 Mon Sep 17 00:00:00 2001 From: Justin Williamson Date: Sat, 11 Oct 2025 23:16:28 -0400 Subject: [PATCH 6/8] Improve exception handling, preserve original ABI --- src/Cronos/CronExpression.cs | 77 ++++++++++++++++++++++- src/Cronos/CronFormat.cs | 2 +- src/Cronos/CronParser.cs | 22 +++---- src/Cronos/MissingSeedException.cs | 72 +++++++++++++++++++++ tests/Cronos.Tests/CronExpressionFacts.cs | 12 ++-- 5 files changed, 164 insertions(+), 21 deletions(-) create mode 100644 src/Cronos/MissingSeedException.cs diff --git a/src/Cronos/CronExpression.cs b/src/Cronos/CronExpression.cs index dff6747..c895e58 100644 --- a/src/Cronos/CronExpression.cs +++ b/src/Cronos/CronExpression.cs @@ -174,7 +174,18 @@ internal CronExpression( /// minute, hour, day of month, month, day of week. /// See more: https://github.com/HangfireIO/Cronos /// - public static CronExpression Parse(string expression, int? jitterSeed = null) + public static CronExpression Parse(string expression) + { + return Parse(expression, CronFormat.Standard); + } + + /// + /// Constructs a new based on the specified + /// cron expression and jitter seed. Its supported expressions consist of 5 fields: + /// minute, hour, day of month, month, day of week. + /// See more: https://github.com/HangfireIO/Cronos + /// + public static CronExpression Parse(string expression, int jitterSeed) { return Parse(expression, CronFormat.Standard, jitterSeed); } @@ -185,7 +196,24 @@ public static CronExpression Parse(string expression, int? jitterSeed = null) /// second (optional), minute, hour, day of month, month, day of week. /// See more: https://github.com/HangfireIO/Cronos /// - public static CronExpression Parse(string expression, CronFormat format, int? jitterSeed = null) + public static CronExpression Parse(string expression, CronFormat format) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(expression); +#else + if (expression == null) throw new ArgumentNullException(nameof(expression)); +#endif + + return CronParser.Parse(expression, format); + } + + /// + /// Constructs a new based on the specified + /// cron expression and jitter seed. Its supported expressions consist of 5 or 6 fields: + /// second (optional), minute, hour, day of month, month, day of week. + /// See more: https://github.com/HangfireIO/Cronos + /// + public static CronExpression Parse(string expression, CronFormat format, int jitterSeed) { #if NET6_0_OR_GREATER ArgumentNullException.ThrowIfNull(expression); @@ -206,6 +234,16 @@ public static bool TryParse(string expression, [MaybeNullWhen(returnValue: false return TryParse(expression, CronFormat.Standard, out cronExpression); } + /// + /// Constructs a new based on the specified cron expression with the + /// format. + /// A return value indicates whether the operation succeeded. + /// + public static bool TryParse(string expression, int jitterSeed, [MaybeNullWhen(returnValue: false)] out CronExpression cronExpression) + { + return TryParse(expression, CronFormat.Standard, jitterSeed, out cronExpression); + } + /// /// Constructs a new based on the specified cron expression with the specified /// . @@ -229,6 +267,41 @@ public static bool TryParse(string expression, CronFormat format, [MaybeNullWhen cronExpression = null; return false; } + catch (MissingSeedException) + { + cronExpression = null; + return false; + } + } + + /// + /// Constructs a new based on the specified cron expression with the specified + /// . + /// A return value indicates whether the operation succeeded. + /// + public static bool TryParse(string expression, CronFormat format, int jitterSeed, [MaybeNullWhen(returnValue: false)] out CronExpression cronExpression) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(expression); +#else + if (expression == null) throw new ArgumentNullException(nameof(expression)); +#endif + + try + { + cronExpression = Parse(expression, format, jitterSeed); + return true; + } + catch (CronFormatException) + { + cronExpression = null; + return false; + } + catch (MissingSeedException) + { + cronExpression = null; + return false; + } } /// diff --git a/src/Cronos/CronFormat.cs b/src/Cronos/CronFormat.cs index ab13511..994fd72 100644 --- a/src/Cronos/CronFormat.cs +++ b/src/Cronos/CronFormat.cs @@ -25,7 +25,7 @@ namespace Cronos { /// - /// Defines the cron format options that customize string parsing for . + /// Defines the cron format options that customize string parsing for . /// [Flags] public enum CronFormat diff --git a/src/Cronos/CronParser.cs b/src/Cronos/CronParser.cs index 34f6df6..901c24d 100644 --- a/src/Cronos/CronParser.cs +++ b/src/Cronos/CronParser.cs @@ -247,11 +247,7 @@ private static unsafe ulong ParseField(CronField field, ref char* pointer, ref C return ParseStar(field, ref pointer); } - if (Accept(ref pointer, 'H')) - { - if (field.CanDefineInterval) flags |= CronExpressionFlag.Interval; - return ParseHash(field, ref pointer, rng); - } + if (Accept(ref pointer, 'H')) return ParseHash(field, ref pointer, ref flags, rng); var num = ParseValue(field, ref pointer); @@ -267,7 +263,7 @@ private static unsafe ulong ParseDayOfMonth(ref char* pointer, ref CronExpressio if (Accept(ref pointer, '*') || Accept(ref pointer, '?')) return ParseStar(field, ref pointer); - if (Accept(ref pointer, 'H')) return ParseHash(field, ref pointer, rng); + if (Accept(ref pointer, 'H')) return ParseHash(field, ref pointer, ref flags, rng); if (AcceptCharacter(ref pointer, 'L')) return ParseLastDayOfMonth(field, ref pointer, ref flags, ref lastDayOffset); @@ -290,7 +286,7 @@ private static unsafe ulong ParseDayOfWeek(ref char* pointer, ref CronExpression var field = CronField.DaysOfWeek; if (Accept(ref pointer, '*') || Accept(ref pointer, '?')) return ParseStar(field, ref pointer); - if (Accept(ref pointer, 'H')) return ParseHash(field, ref pointer, rng); + if (Accept(ref pointer, 'H')) return ParseHash(field, ref pointer, ref flags, rng); var dayOfWeek = ParseValue(field, ref pointer); @@ -311,17 +307,21 @@ private static unsafe ulong ParseStar(CronField field, ref char* pointer) } [SuppressMessage("Security", "CA5394:Do not use insecure randomness")] - private static unsafe ulong ParseHash(CronField field, ref char* pointer, Random? rng) + private static unsafe ulong ParseHash(CronField field, ref char* pointer, ref CronExpressionFlag flags, Random? rng) { // prior to this point in parsing, it has been valid to not pass in a jitter seed. - if (rng == null) throw new ArgumentNullException(nameof(rng), "Using H in the format requires providing a jitter seed"); + if (rng == null) throw new MissingSeedException(); // Prevent against calculating the 31st of February var maxValueInclusive = field == CronField.DaysOfMonth ? CronField.LastCommonDayOfMonth : field.Last; - if (Accept(ref pointer, '/')) return ParseHashStep(field, ref pointer, field.First, maxValueInclusive, rng); + if (Accept(ref pointer, '/')) + { + if (field.CanDefineInterval) flags |= CronExpressionFlag.Interval; + return ParseHashStep(field, ref pointer, field.First, maxValueInclusive, rng); + } var jitter = rng.Next(field.First, maxValueInclusive + 1); return GetBit(jitter); @@ -366,7 +366,7 @@ private static unsafe ulong ParseStep(CronField field, ref char* pointer, int lo [SuppressMessage("Security", "CA5394:Do not use insecure randomness")] private static unsafe ulong ParseHashStep(CronField field, ref char* pointer, int low, int high, Random rng) { - // field range may have been truncated, e.g. day of month + // field range may have been truncated, e.g., day of month var step = ParseNumber(field, ref pointer, 1, high); // rather than generate an offset somewhere in the field's range, we'll instead generate an offset in the diff --git a/src/Cronos/MissingSeedException.cs b/src/Cronos/MissingSeedException.cs new file mode 100644 index 0000000..476ac0f --- /dev/null +++ b/src/Cronos/MissingSeedException.cs @@ -0,0 +1,72 @@ +// The MIT License(MIT) +// +// Copyright (c) 2017 Hangfire OÜ +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System; +#if !NETSTANDARD1_0 +using System.Runtime.Serialization; +#endif + +namespace Cronos +{ + /// + /// Represents an exception thrown when the cron expression requires jitter, but a seed wasn't provided + /// +#if !NETSTANDARD1_0 + [Serializable] +#endif + public class MissingSeedException : Exception + { + internal const string BaseMessage = "Using H in the format requires providing a jitter seed."; + + /// + /// Initializes a new instance of the class. + /// + public MissingSeedException() : this(BaseMessage) + { + } + + /// + /// Initializes a new instance of the class with + /// a specified error message. + /// + public MissingSeedException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with + /// a specified error message and a reference to the inner exception that is the + /// cause of this exception. + /// + public MissingSeedException(string message, Exception innerException) + : base(message, innerException) + { + } + +#if !NETSTANDARD1_0 + /// + protected MissingSeedException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } +#endif + } +} \ No newline at end of file diff --git a/tests/Cronos.Tests/CronExpressionFacts.cs b/tests/Cronos.Tests/CronExpressionFacts.cs index 9f0821a..70948c2 100644 --- a/tests/Cronos.Tests/CronExpressionFacts.cs +++ b/tests/Cronos.Tests/CronExpressionFacts.cs @@ -24,6 +24,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Reflection; using System.Text.RegularExpressions; using Xunit; @@ -498,9 +499,7 @@ public void Parse_DoesNotThrowAnException_WhenExpressionIsMacro(string cronExpre [Fact] public void Parse_ThrowsAnException_WhenHashIsPresentAndNoRandomIsProvided() { - var exception = Assert.Throws(() => CronExpression.Parse("H * * * *")); - Assert.Equal("rng", exception.ParamName); - Assert.StartsWith("Using H in the format requires providing a jitter seed", exception.Message); + Assert.Throws(() => CronExpression.Parse("H * * * *")); } [Fact] @@ -554,11 +553,10 @@ public void TryParse_WithSecondsSpecified_ReturnsTrue_AndGivesSecondBasedCronExp } [Fact] - public void TryParse_ThrowsAnException_WhenHashIsPresentAndNoRandomIsProvided() + public void TryParse_ReturnsFalse_WhenHashIsPresentAndNoRandomIsProvided() { - var exception = Assert.Throws(() => CronExpression.TryParse("H * * * *", out _)); - Assert.Equal("rng", exception.ParamName); - Assert.StartsWith("Using H in the format requires providing a jitter seed", exception.Message); + var result = CronExpression.TryParse("H * * * *", out _); + Assert.False(result); } [Theory] From 964c1a4261ca543fb7dac655db69724528037e49 Mon Sep 17 00:00:00 2001 From: Justin Williamson Date: Sat, 11 Oct 2025 23:24:30 -0400 Subject: [PATCH 7/8] Fixup typos --- src/Cronos/CronExpression.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Cronos/CronExpression.cs b/src/Cronos/CronExpression.cs index c895e58..83a56a0 100644 --- a/src/Cronos/CronExpression.cs +++ b/src/Cronos/CronExpression.cs @@ -235,8 +235,8 @@ public static bool TryParse(string expression, [MaybeNullWhen(returnValue: false } /// - /// Constructs a new based on the specified cron expression with the - /// format. + /// Constructs a new based on the specified cron expression and jitter seed with + /// the format. /// A return value indicates whether the operation succeeded. /// public static bool TryParse(string expression, int jitterSeed, [MaybeNullWhen(returnValue: false)] out CronExpression cronExpression) @@ -275,8 +275,8 @@ public static bool TryParse(string expression, CronFormat format, [MaybeNullWhen } /// - /// Constructs a new based on the specified cron expression with the specified - /// . + /// Constructs a new based on the specified cron expression and jitter seed with + /// the specified . /// A return value indicates whether the operation succeeded. /// public static bool TryParse(string expression, CronFormat format, int jitterSeed, [MaybeNullWhen(returnValue: false)] out CronExpression cronExpression) From ce21036f7f9b7b2d79a9c256ab731778fcbc6706 Mon Sep 17 00:00:00 2001 From: Justin Williamson Date: Sun, 12 Oct 2025 14:57:44 -0400 Subject: [PATCH 8/8] Add interval tests for hashes --- tests/Cronos.Tests/CronExpressionFacts.cs | 30 +++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/tests/Cronos.Tests/CronExpressionFacts.cs b/tests/Cronos.Tests/CronExpressionFacts.cs index 70948c2..6636e12 100644 --- a/tests/Cronos.Tests/CronExpressionFacts.cs +++ b/tests/Cronos.Tests/CronExpressionFacts.cs @@ -1201,9 +1201,22 @@ public void GetNextOccurrence_RoundsFromUtcUpToTheSecond(bool inclusiveFrom, int [InlineData("0 0,59 * * * * ", "2016-03-13 01:59 -05:00", "2016-03-13 03:00 -04:00", false)] [InlineData("0 30 * * 3 SUN#2", "2016-03-13 01:59 -05:00", "2016-03-13 03:00 -04:00", false)] + + // Run twice for hashes with intervals -- hash offset resolves to 8 minutes + [InlineData("0 H/30 * * * *", "2016-03-13 01:08 -05:00", "2016-03-13 01:08 -05:00", true)] + [InlineData("0 H/30 * * * *", "2016-03-13 01:38 -05:00", "2016-03-13 01:38 -05:00", true)] + [InlineData("0 H/30 * * * *", "2016-03-13 01:59 -05:00", "2016-03-13 03:00 -04:00", true)] // documented limitation + [InlineData("0 H/30 * * * *", "2016-03-13 03:15 -04:00", "2016-03-13 03:38 -04:00", true)] + [InlineData("0 H/30 * * * *", "2016-03-13 03:38 -04:00", "2016-03-13 03:38 -04:00", true)] + [InlineData("0 H/30 * * * *", "2016-03-13 03:45 -04:00", "2016-03-13 04:08 -04:00", true)] + [InlineData("0 H/30 * * * *", "2016-03-13 01:08 -05:00", "2016-03-13 01:38 -05:00", false)] + [InlineData("0 H/30 * * * *", "2016-03-13 01:38 -05:00", "2016-03-13 03:00 -04:00", false)] // documented limitation + [InlineData("0 H/30 * * * *", "2016-03-13 03:08 -04:00", "2016-03-13 03:38 -04:00", false)] + [InlineData("0 H/30 * * * *", "2016-03-13 03:38 -04:00", "2016-03-13 04:08 -04:00", false)] public void GetNextOccurrence_HandleDST_WhenTheClockJumpsForward_And_TimeZoneIsEst(string cronExpression, string fromString, string expectedString, bool inclusive) { - var expression = CronExpression.Parse(cronExpression, CronFormat.IncludeSeconds); + var jitterSeed = 3; + var expression = CronExpression.Parse(cronExpression, CronFormat.IncludeSeconds, jitterSeed); var fromInstant = GetInstant(fromString); var expectedInstant = GetInstant(expectedString); @@ -1347,6 +1360,18 @@ public void GetNextOccurrence_HandleDST_WhenTheClockTurnForwardHalfHour(string c [InlineData("0 */30 * * * *", "2016-11-06 01:30 -04:00", "2016-11-06 01:00 -05:00", false)] [InlineData("0 */30 * * * *", "2016-11-06 01:00 -05:00", "2016-11-06 01:30 -05:00", false)] [InlineData("0 */30 * * * *", "2016-11-06 01:30 -05:00", "2016-11-06 02:00 -05:00", false)] + + // Run twice for hashes with intervals -- hash offset resolves to 8 minutes + [InlineData("0 H/30 * * * *", "2016-11-06 01:08 -04:00", "2016-11-06 01:08 -04:00", true)] + [InlineData("0 H/30 * * * *", "2016-11-06 01:38 -04:00", "2016-11-06 01:38 -04:00", true)] + [InlineData("0 H/30 * * * *", "2016-11-06 01:59 -04:00", "2016-11-06 01:08 -05:00", true)] + [InlineData("0 H/30 * * * *", "2016-11-06 01:15 -05:00", "2016-11-06 01:38 -05:00", true)] + [InlineData("0 H/30 * * * *", "2016-11-06 01:38 -05:00", "2016-11-06 01:38 -05:00", true)] + [InlineData("0 H/30 * * * *", "2016-11-06 01:45 -05:00", "2016-11-06 02:08 -05:00", true)] + [InlineData("0 H/30 * * * *", "2016-11-06 01:08 -04:00", "2016-11-06 01:38 -04:00", false)] + [InlineData("0 H/30 * * * *", "2016-11-06 01:38 -04:00", "2016-11-06 01:08 -05:00", false)] + [InlineData("0 H/30 * * * *", "2016-11-06 01:08 -05:00", "2016-11-06 01:38 -05:00", false)] + [InlineData("0 H/30 * * * *", "2016-11-06 01:38 -05:00", "2016-11-06 02:08 -05:00", false)] [InlineData("0 30 * * * *", "2016-11-06 01:30 -04:00", "2016-11-06 01:30 -04:00", true)] [InlineData("0 30 * * * *", "2016-11-06 01:59 -04:00", "2016-11-06 01:30 -05:00", true)] @@ -1456,7 +1481,8 @@ public void GetNextOccurrence_HandleDST_WhenTheClockTurnForwardHalfHour(string c [InlineData("0 0 2 * * *", "2016-11-06 01:45 -05:00", "2016-11-06 02:00 -05:00", false)] public void GetNextOccurrence_HandleDST_WhenTheClockJumpsBackward(string cronExpression, string fromString, string expectedString, bool inclusive) { - var expression = CronExpression.Parse(cronExpression, CronFormat.IncludeSeconds); + var jitterSeed = 3; + var expression = CronExpression.Parse(cronExpression, CronFormat.IncludeSeconds, jitterSeed); var fromInstant = GetInstant(fromString); var expectedInstant = GetInstant(expectedString);