Skip to content

Commit 4c27ebd

Browse files
committed
Adding support for the H syntax, allowing to add jitter to a cron expression
1 parent a306f80 commit 4c27ebd

File tree

9 files changed

+192
-32
lines changed

9 files changed

+192
-32
lines changed

README.md

Lines changed: 71 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,16 @@ npm install cron-parser
3535

3636
### Special Characters
3737

38-
| Character | Description | Example |
39-
| --------- | ------------------------- | --------------------------------------------- |
40-
| `*` | Any value | `* * * * *` (every minute) |
41-
| `?` | Any value (alias for `*`) | `? * * * *` (every minute) |
42-
| `,` | Value list separator | `1,2,3 * * * *` (1st, 2nd, and 3rd minute) |
43-
| `-` | Range of values | `1-5 * * * *` (every minute from 1 through 5) |
44-
| `/` | Step values | `*/5 * * * *` (every 5th minute) |
45-
| `L` | Last day of month/week | `0 0 L * *` (midnight on last day of month) |
46-
| `#` | Nth day of month | `0 0 * * 1#1` (first Monday of month) |
38+
| Character | Description | Example |
39+
| --------- | ------------------------- | ---------------------------------------------------------------------- |
40+
| `*` | Any value | `* * * * *` (every minute) |
41+
| `?` | Any value (alias for `*`) | `? * * * *` (every minute) |
42+
| `,` | Value list separator | `1,2,3 * * * *` (1st, 2nd, and 3rd minute) |
43+
| `-` | Range of values | `1-5 * * * *` (every minute from 1 through 5) |
44+
| `/` | Step values | `*/5 * * * *` (every 5th minute) |
45+
| `L` | Last day of month/week | `0 0 L * *` (midnight on last day of month) |
46+
| `#` | Nth day of month | `0 0 * * 1#1` (first Monday of month) |
47+
| `H` | Randomized value | `H * * * *` (every n minute where n is randomly picked within [0, 59]) |
4748

4849
### Predefined Expressions
4950

@@ -61,24 +62,25 @@ npm install cron-parser
6162

6263
### Field Values
6364

64-
| Field | Values | Special Characters | Aliases |
65-
| ------------ | ------ | --------------------------- | ------------------------------ |
66-
| second | 0-59 | `*` `?` `,` `-` `/` | |
67-
| minute | 0-59 | `*` `?` `,` `-` `/` | |
68-
| hour | 0-23 | `*` `?` `,` `-` `/` | |
69-
| day of month | 1-31 | `*` `?` `,` `-` `/` `L` | |
70-
| month | 1-12 | `*` `?` `,` `-` `/` | `JAN`-`DEC` |
71-
| day of week | 0-7 | `*` `?` `,` `-` `/` `L` `#` | `SUN`-`SAT` (0 or 7 is Sunday) |
65+
| Field | Values | Special Characters | Aliases |
66+
| ------------ | ------ | ------------------------------- | ------------------------------ |
67+
| second | 0-59 | `*` `?` `,` `-` `/` `H` | |
68+
| minute | 0-59 | `*` `?` `,` `-` `/` `H` | |
69+
| hour | 0-23 | `*` `?` `,` `-` `/` `H` | |
70+
| day of month | 1-31 | `*` `?` `,` `-` `/` `H` `L` | |
71+
| month | 1-12 | `*` `?` `,` `-` `/` `H` | `JAN`-`DEC` |
72+
| day of week | 0-7 | `*` `?` `,` `-` `/` `H` `L` `#` | `SUN`-`SAT` (0 or 7 is Sunday) |
7273

7374
## Options
7475

75-
| Option | Type | Description |
76-
| ----------- | ------------------------ | -------------------------------------------------------------- |
77-
| currentDate | Date \| string \| number | Current date. Defaults to current local time in UTC |
78-
| endDate | Date \| string \| number | End date of iteration range. Sets iteration range end point |
79-
| startDate | Date \| string \| number | Start date of iteration range. Set iteration range start point |
80-
| tz | string | Timezone (e.g., 'Europe/London') |
81-
| strict | boolean | Enable strict mode validation |
76+
| Option | Type | Description |
77+
| ----------- | ------------------------ | --------------------------------------------------------------- |
78+
| currentDate | Date \| string \| number | Current date. Defaults to current local time in UTC |
79+
| endDate | Date \| string \| number | End date of iteration range. Sets iteration range end point |
80+
| startDate | Date \| string \| number | Start date of iteration range. Set iteration range start point |
81+
| tz | string | Timezone (e.g., 'Europe/London') |
82+
| seed | string | A seed to be used in conjunction with the `H` special character |
83+
| strict | boolean | Enable strict mode validation |
8284

8385
When using string dates, the following formats are supported:
8486

@@ -290,6 +292,51 @@ console.log(modified2.stringify()); // "30 15 * * 1-5"
290292

291293
The `CronFieldCollection.from` method accepts either CronField instances or raw values that would be valid for creating new CronField instances. This is particularly useful when you need to modify only specific fields while keeping others unchanged.
292294

295+
### Hash support
296+
297+
The library support adding [jitter](https://en.wikipedia.org/wiki/Jitter) to the returned intervals using the `H` special character in a field. When `H` is specified instead of `*`, a random value is used (`H` is replaced by `23`, where 23 is picked randomly, within the valid range of the field).
298+
299+
This jitter allows to spread the load when it comes to job scheduling. This feature is inspired by Jenkins's cron syntax.
300+
301+
```typescript
302+
import { CronExpressionParser } from 'cron-parser';
303+
304+
// At 23:<randomized> on every day-of-week from Monday through Friday.
305+
const interval = CronExpressionParser.parse('H 23 * * 1-5');
306+
307+
// At <randomized>:30 everyday.
308+
const interval = CronExpressionParser.parse('30 H * * *');
309+
310+
// At every minutes of <randomized> hour at <randomized> second everyday.
311+
const interval = CronExpressionParser.parse('H * H * * *');
312+
313+
// At every 5th minute from <randomized> through 59 everyday.
314+
const interval = CronExpressionParser.parse('H/5 * * * *');
315+
316+
// At every minute of the third <randomized> day of the month
317+
const interval = CronExpressionParser.parse('* * * * H#3');
318+
```
319+
320+
The randomness is seed-able using the `seed` option of `CronExpressionOptions`:
321+
322+
```typescript
323+
import { CronExpressionParser } from 'cron-parser';
324+
325+
const options = {
326+
currentDate: '2023-03-26T01:00:00',
327+
seed: 'main-backup', // Generally, seed would be a job name for example
328+
};
329+
330+
const interval = CronExpressionParser.parse('H * * * H', options);
331+
332+
console.log(interval.stringify()); // "12 * * * 4"
333+
334+
const otherInterval = CronExpressionParser.parse('H * * * H', options);
335+
336+
// Using the same seed will always return the same jitter
337+
console.log(otherInterval.stringify()); // "12 * * * 4"
338+
```
339+
293340
## License
294341

295342
MIT

package-lock.json

Lines changed: 16 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,15 @@
3232
"README.md"
3333
],
3434
"dependencies": {
35-
"luxon": "^3.6.1"
35+
"luxon": "^3.6.1",
36+
"seedrandom": "^3.0.5"
3637
},
3738
"devDependencies": {
3839
"@tsd/typescript": "^5.8.2",
3940
"@types/jest": "^29.5.14",
4041
"@types/luxon": "^3.6.2",
4142
"@types/node": "^22.14.0",
43+
"@types/seedrandom": "^3.0.8",
4244
"@typescript-eslint/eslint-plugin": "^8.29.0",
4345
"@typescript-eslint/parser": "^8.29.0",
4446
"chalk": "^5.4.1",

src/CronExpression.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type CronExpressionOptions = {
1010
nthDayOfWeek?: number;
1111
expression?: string;
1212
strict?: boolean;
13+
seed?: string;
1314
};
1415

1516
/**

src/CronExpressionParser.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import seedrandom, { type PRNG } from 'seedrandom';
2+
13
import { CronFieldCollection } from './CronFieldCollection';
24
import { CronDate } from './CronDate';
35
import { CronExpression, CronExpressionOptions } from './CronExpression';
@@ -85,7 +87,6 @@ export class CronExpressionParser {
8587
* Parses a cron expression and returns a CronExpression object.
8688
* @param {string} expression - The cron expression to parse.
8789
* @param {CronExpressionOptions} [options={}] - The options to use when parsing the expression.
88-
* @param {boolean} [options.currentDate=false] - If true, will throw an error if the expression contains both dayOfMonth and dayOfWeek.
8990
* @param {boolean} [options.strict=false] - If true, will throw an error if the expression contains both dayOfMonth and dayOfWeek.
9091
* @param {CronDate} [options.currentDate=new CronDate(undefined, 'UTC')] - The date to use when calculating the next/previous occurrence.
9192
*
@@ -95,6 +96,8 @@ export class CronExpressionParser {
9596
const { strict = false } = options;
9697
const currentDate = options.currentDate || new CronDate();
9798

99+
const rand = options.seed ? seedrandom(options.seed) : seedrandom();
100+
98101
expression = PredefinedExpressions[expression as keyof typeof PredefinedExpressions] || expression;
99102
const rawFields = CronExpressionParser.#getRawFields(expression, strict);
100103
if (!(rawFields.dayOfMonth === '*' || rawFields.dayOfWeek === '*' || !strict)) {
@@ -105,28 +108,38 @@ export class CronExpressionParser {
105108
CronUnit.Second,
106109
rawFields.second,
107110
CronSecond.constraints,
111+
rand,
108112
) as SixtyRange[];
109113
const minute = CronExpressionParser.#parseField(
110114
CronUnit.Minute,
111115
rawFields.minute,
112116
CronMinute.constraints,
117+
rand,
113118
) as SixtyRange[];
114-
const hour = CronExpressionParser.#parseField(CronUnit.Hour, rawFields.hour, CronHour.constraints) as HourRange[];
119+
const hour = CronExpressionParser.#parseField(
120+
CronUnit.Hour,
121+
rawFields.hour,
122+
CronHour.constraints,
123+
rand,
124+
) as HourRange[];
115125
const month = CronExpressionParser.#parseField(
116126
CronUnit.Month,
117127
rawFields.month,
118128
CronMonth.constraints,
129+
rand,
119130
) as MonthRange[];
120131
const dayOfMonth = CronExpressionParser.#parseField(
121132
CronUnit.DayOfMonth,
122133
rawFields.dayOfMonth,
123134
CronDayOfMonth.constraints,
135+
rand,
124136
) as DayOfMonthRange[];
125137
const { dayOfWeek: _dayOfWeek, nthDayOfWeek } = CronExpressionParser.#parseNthDay(rawFields.dayOfWeek);
126138
const dayOfWeek = CronExpressionParser.#parseField(
127139
CronUnit.DayOfWeek,
128140
_dayOfWeek,
129141
CronDayOfWeek.constraints,
142+
rand,
130143
) as DayOfWeekRange[];
131144

132145
const fields = new CronFieldCollection({
@@ -175,7 +188,7 @@ export class CronExpressionParser {
175188
* @private
176189
* @returns {(number | string)[]} The parsed field.
177190
*/
178-
static #parseField(field: CronUnit, value: string, constraints: CronConstraints): (number | string)[] {
191+
static #parseField(field: CronUnit, value: string, constraints: CronConstraints, rand: PRNG): (number | string)[] {
179192
// Replace aliases for month and dayOfWeek
180193
if (field === CronUnit.Month || field === CronUnit.DayOfWeek) {
181194
value = value.replace(/[a-z]{3}/gi, (match) => {
@@ -195,6 +208,8 @@ export class CronExpressionParser {
195208

196209
// Replace '*' and '?'
197210
value = value.replace(/[*?]/g, constraints.min + '-' + constraints.max);
211+
// Replace 'H' using the seeded PRNG
212+
value = value.replace(/H/g, String(Math.floor(rand() * (constraints.max - constraints.min + 1) + constraints.min)));
198213
return CronExpressionParser.#parseSequence(field, value, constraints);
199214
}
200215

src/fields/CronDayOfMonth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export class CronDayOfMonth extends CronField {
2323
return DAY_CHARS;
2424
}
2525
static get validChars(): RegExp {
26-
return /^[?,*\dL/-]+$/;
26+
return /^[?,*H\dLH/-]+$/;
2727
}
2828
/**
2929
* CronDayOfMonth constructor. Initializes the "day of the month" field with the provided values.

src/fields/CronDayOfWeek.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export class CronDayOfWeek extends CronField {
2424
}
2525

2626
static get validChars(): RegExp {
27-
return /^[?,*\dL#/-]+$/;
27+
return /^[?,*\dLH#/-]+$/;
2828
}
2929

3030
/**

src/fields/CronField.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export abstract class CronField {
4343
* Returns the regular expression used to validate this field.
4444
*/
4545
static get validChars(): RegExp {
46-
return /^[,*\d/-]+$/;
46+
return /^[,*\dH/-]+$/;
4747
}
4848

4949
/**

tests/CronExpressionParser.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1684,4 +1684,84 @@ describe('CronExpressionParser', () => {
16841684
}).toThrow();
16851685
});
16861686
});
1687+
1688+
describe('test expressions using the hash extension syntax', () => {
1689+
// Not having a seed is making tests less useful
1690+
describe('without a custom seed', () => {
1691+
test('parses expressions using H on all fields', () => {
1692+
const options = {
1693+
currentDate: new Date(2025, 0, 1),
1694+
};
1695+
1696+
const expressions = [
1697+
'H * * * * *',
1698+
'* H * * * *',
1699+
'* * H * * *',
1700+
'* * * H * *',
1701+
'* * * * H *',
1702+
'* * * * * H',
1703+
'H H * * * *',
1704+
'* H H * * *',
1705+
'* * H H * *',
1706+
'* * * H H *',
1707+
'* * * * H H',
1708+
'H H H H H H',
1709+
'H/5 * * * * *',
1710+
'* * * * * H#1',
1711+
];
1712+
1713+
for (const expression of expressions) {
1714+
const interval = CronExpressionParser.parse(expression, options);
1715+
1716+
for (var i = 0; i < 3; i++) {
1717+
let date = interval.next();
1718+
1719+
expect(date.getSeconds()).toBeGreaterThanOrEqual(0);
1720+
expect(date.getSeconds()).toBeLessThanOrEqual(59);
1721+
expect(date.getMinutes()).toBeGreaterThanOrEqual(0);
1722+
expect(date.getMinutes()).toBeLessThanOrEqual(59);
1723+
expect(date.getHours()).toBeGreaterThanOrEqual(0);
1724+
expect(date.getHours()).toBeLessThanOrEqual(23);
1725+
expect(date.getDate()).toBeGreaterThanOrEqual(1);
1726+
expect(date.getDate()).toBeLessThanOrEqual(31);
1727+
expect(date.getMonth()).toBeGreaterThanOrEqual(0);
1728+
expect(date.getMonth()).toBeLessThanOrEqual(11);
1729+
expect(date.getDay()).toBeGreaterThanOrEqual(0);
1730+
expect(date.getDay()).toBeLessThanOrEqual(7);
1731+
expect(date.getFullYear()).toBe(2025);
1732+
}
1733+
}
1734+
});
1735+
});
1736+
1737+
describe('with a custom seed', () => {
1738+
test('parses expressions using H on all fields', () => {
1739+
const options = {
1740+
currentDate: new Date(2025, 0, 1),
1741+
seed: 'F00D',
1742+
};
1743+
1744+
const expressions = [
1745+
{ expression: 'H * * * * *', expected: '10 * * * * *' },
1746+
{ expression: '* H * * * *', expected: '* 33 * * * *' },
1747+
{ expression: '* * H * * *', expected: '* * 19 * * *' },
1748+
{ expression: '* * * H * *', expected: '* * * 27 * *' },
1749+
{ expression: '* * * * H *', expected: '* * * * 8 *' },
1750+
{ expression: '* * * * * H', expected: '* * * * * 1' },
1751+
{ expression: 'H H * * * *', expected: '10 33 * * * *' },
1752+
{ expression: '* H H * * *', expected: '* 33 19 * * *' },
1753+
{ expression: '* * H H * *', expected: '* * 19 27 * *' },
1754+
{ expression: '* * * H H *', expected: '* * * 27 8 *' },
1755+
{ expression: '* * * * H H', expected: '* * * * 8 1' },
1756+
{ expression: 'H H H H H H', expected: '10 33 19 27 8 1' },
1757+
{ expression: 'H/5 * * * * *', expected: '10/5 * * * * *' },
1758+
{ expression: '* * * * * H#1', expected: '* * * * * 1' },
1759+
];
1760+
1761+
for (const { expression, expected } of expressions) {
1762+
expect(CronExpressionParser.parse(expression, options).stringify(true)).toBe(expected);
1763+
}
1764+
});
1765+
});
1766+
});
16871767
});

0 commit comments

Comments
 (0)