diff --git a/README.md b/README.md
index 36cfcc8..8e2bc35 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,43 @@ 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.
+
+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:
+
+| 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..83a56a0 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,9 +170,8 @@ 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)
@@ -140,7 +181,18 @@ public static CronExpression Parse(string expression)
///
/// Constructs a new based on the specified
- /// cron expression. It's supported expressions consisting of 5 or 6 fields:
+ /// 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);
+ }
+
+ ///
+ /// Constructs a new based on the specified
+ /// 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
///
@@ -155,6 +207,23 @@ public static CronExpression Parse(string expression, CronFormat format)
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);
+#else
+ if (expression == null) throw new ArgumentNullException(nameof(expression));
+#endif
+
+ return CronParser.Parse(expression, format, jitterSeed);
+ }
+
///
/// Constructs a new based on the specified cron expression with the
/// format.
@@ -165,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 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)
+ {
+ return TryParse(expression, CronFormat.Standard, jitterSeed, out cronExpression);
+ }
+
///
/// Constructs a new based on the specified cron expression with the specified
/// .
@@ -188,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 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)
+ {
+#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/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/CronParser.cs b/src/Cronos/CronParser.cs
index 1a5dd28..901c24d 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,15 @@ 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')) return ParseHash(field, ref pointer, ref flags, rng);
var num = ParseValue(field, ref pointer);
@@ -235,11 +257,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, ref flags, rng);
if (AcceptCharacter(ref pointer, 'L')) return ParseLastDayOfMonth(field, ref pointer, ref flags, ref lastDayOffset);
@@ -257,10 +281,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, ref flags, rng);
var dayOfWeek = ParseValue(field, ref pointer);
@@ -280,6 +306,27 @@ 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, 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 MissingSeedException();
+
+ // Prevent against calculating the 31st of February
+ var maxValueInclusive = field == CronField.DaysOfMonth
+ ? CronField.LastCommonDayOfMonth
+ : field.Last;
+
+ 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);
+ }
+
private static unsafe ulong ParseList(CronField field, ref char* pointer, ref CronExpressionFlag flags)
{
var num = ParseValue(field, ref pointer);
@@ -312,14 +359,22 @@ private static unsafe ulong ParseRange(CronField field, ref char* pointer, int l
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);
}
+ [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)
{
flags |= CronExpressionFlag.DayOfMonthLast;
@@ -399,13 +454,14 @@ private static ulong GetRangeBits(int low, int high, int 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/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 7e2e1d4..6636e12 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;
@@ -494,6 +495,12 @@ public void Parse_DoesNotThrowAnException_WhenExpressionIsMacro(string cronExpre
{
CronExpression.Parse(cronExpression, format);
}
+
+ [Fact]
+ public void Parse_ThrowsAnException_WhenHashIsPresentAndNoRandomIsProvided()
+ {
+ Assert.Throws(() => CronExpression.Parse("H * * * *"));
+ }
[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_ReturnsFalse_WhenHashIsPresentAndNoRandomIsProvided()
+ {
+ var result = CronExpression.TryParse("H * * * *", out _);
+ Assert.False(result);
+ }
+
[Theory]
[InlineData(DateTimeKind.Unspecified, false)]
[InlineData(DateTimeKind.Unspecified, true)]
@@ -1187,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);
@@ -1333,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)]
@@ -1442,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);
@@ -2447,6 +2487,61 @@ 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 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);
+
+ 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()
{