Skip to content

Commit f4f3947

Browse files
MYDIHharrisiirak
andauthored
Adding support for the H syntax, allowing to add jitter to a cron expression (#377)
* Adding support for the H syntax, allowing to add jitter to a cron expression * Fixing a repetition of in the allowed characters of CronDayOfMonth.ts * Use a small seeded PRNG directly embedded in the code * Update src/CronExpressionParser.ts Co-authored-by: Harri Siirak <[email protected]> * Adding some test cases for the utils/random.ts functions * Renaming `seed` to `hashSeed` * Add some JSDoc to utils/random.ts --------- Co-authored-by: Harri Siirak <[email protected]>
1 parent 9a5fa65 commit f4f3947

File tree

9 files changed

+248
-30
lines changed

9 files changed

+248
-30
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+
| hashSeed | 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 `hashSeed` option of `CronExpressionOptions`:
321+
322+
```typescript
323+
import { CronExpressionParser } from 'cron-parser';
324+
325+
const options = {
326+
currentDate: '2023-03-26T01:00:00',
327+
hashSeed: 'main-backup', // Generally, hashSeed 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

src/CronExpression.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type CronExpressionOptions = {
99
tz?: string;
1010
nthDayOfWeek?: number;
1111
expression?: string;
12+
hashSeed?: string;
1213
strict?: boolean;
1314
};
1415

src/CronExpressionParser.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { CronFieldCollection } from './CronFieldCollection';
22
import { CronDate } from './CronDate';
33
import { CronExpression, CronExpressionOptions } from './CronExpression';
4+
import { type PRNG, seededRandom } from './utils/random';
45
import {
56
CronSecond,
67
CronMinute,
@@ -85,7 +86,6 @@ export class CronExpressionParser {
8586
* Parses a cron expression and returns a CronExpression object.
8687
* @param {string} expression - The cron expression to parse.
8788
* @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.
8989
* @param {boolean} [options.strict=false] - If true, will throw an error if the expression contains both dayOfMonth and dayOfWeek.
9090
* @param {CronDate} [options.currentDate=new CronDate(undefined, 'UTC')] - The date to use when calculating the next/previous occurrence.
9191
*
@@ -95,6 +95,8 @@ export class CronExpressionParser {
9595
const { strict = false } = options;
9696
const currentDate = options.currentDate || new CronDate();
9797

98+
const rand = seededRandom(options.hashSeed);
99+
98100
expression = PredefinedExpressions[expression as keyof typeof PredefinedExpressions] || expression;
99101
const rawFields = CronExpressionParser.#getRawFields(expression, strict);
100102
if (!(rawFields.dayOfMonth === '*' || rawFields.dayOfWeek === '*' || !strict)) {
@@ -105,28 +107,38 @@ export class CronExpressionParser {
105107
CronUnit.Second,
106108
rawFields.second,
107109
CronSecond.constraints,
110+
rand,
108111
) as SixtyRange[];
109112
const minute = CronExpressionParser.#parseField(
110113
CronUnit.Minute,
111114
rawFields.minute,
112115
CronMinute.constraints,
116+
rand,
113117
) as SixtyRange[];
114-
const hour = CronExpressionParser.#parseField(CronUnit.Hour, rawFields.hour, CronHour.constraints) as HourRange[];
118+
const hour = CronExpressionParser.#parseField(
119+
CronUnit.Hour,
120+
rawFields.hour,
121+
CronHour.constraints,
122+
rand,
123+
) as HourRange[];
115124
const month = CronExpressionParser.#parseField(
116125
CronUnit.Month,
117126
rawFields.month,
118127
CronMonth.constraints,
128+
rand,
119129
) as MonthRange[];
120130
const dayOfMonth = CronExpressionParser.#parseField(
121131
CronUnit.DayOfMonth,
122132
rawFields.dayOfMonth,
123133
CronDayOfMonth.constraints,
134+
rand,
124135
) as DayOfMonthRange[];
125136
const { dayOfWeek: _dayOfWeek, nthDayOfWeek } = CronExpressionParser.#parseNthDay(rawFields.dayOfWeek);
126137
const dayOfWeek = CronExpressionParser.#parseField(
127138
CronUnit.DayOfWeek,
128139
_dayOfWeek,
129140
CronDayOfWeek.constraints,
141+
rand,
130142
) as DayOfWeekRange[];
131143

132144
const fields = new CronFieldCollection({
@@ -175,7 +187,7 @@ export class CronExpressionParser {
175187
* @private
176188
* @returns {(number | string)[]} The parsed field.
177189
*/
178-
static #parseField(field: CronUnit, value: string, constraints: CronConstraints): (number | string)[] {
190+
static #parseField(field: CronUnit, value: string, constraints: CronConstraints, rand: PRNG): (number | string)[] {
179191
// Replace aliases for month and dayOfWeek
180192
if (field === CronUnit.Month || field === CronUnit.DayOfWeek) {
181193
value = value.replace(/[a-z]{3}/gi, (match) => {
@@ -195,6 +207,8 @@ export class CronExpressionParser {
195207

196208
// Replace '*' and '?'
197209
value = value.replace(/[*?]/g, constraints.min + '-' + constraints.max);
210+
// Replace 'H' using the seeded PRNG
211+
value = value.replace(/H/g, String(Math.floor(rand() * (constraints.max - constraints.min + 1) + constraints.min)));
198212
return CronExpressionParser.#parseSequence(field, value, constraints);
199213
}
200214

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 /^[?,*\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
/**

src/utils/random.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* A type representing a Pseudorandom Number Generator, similar to Math.random()
3+
*/
4+
export type PRNG = () => number;
5+
6+
/**
7+
* Computes a numeric hash from a given string
8+
* @param {string} str A value to hash
9+
* @returns {number} A numeric hash computed from the given value
10+
*/
11+
function xfnv1a(str: string) {
12+
let h = 2166136261 >>> 0;
13+
for (let i = 0; i < str.length; i++) {
14+
h ^= str.charCodeAt(i);
15+
h = Math.imul(h, 16777619);
16+
}
17+
return () => h >>> 0;
18+
}
19+
20+
/**
21+
* Initialize a new PRNG using a given seed
22+
* @param {number} seed The seed used to initialize the PRNG
23+
* @returns {PRNG} A random number generator
24+
*/
25+
function mulberry32(seed: number) {
26+
return () => {
27+
let t = (seed += 0x6d2b79f5);
28+
t = Math.imul(t ^ (t >>> 15), t | 1);
29+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
30+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
31+
};
32+
}
33+
34+
/**
35+
* Generates a PRNG using a given seed. When not provided, the seed is randomly generated
36+
* @param {string} str A string to derive the seed from
37+
* @returns {PRNG} A random number generator correctly seeded
38+
*/
39+
export function seededRandom(str?: string): PRNG {
40+
const seed = str ? xfnv1a(str)() : Math.floor(Math.random() * 10_000_000_000);
41+
return mulberry32(seed);
42+
}

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+
hashSeed: 'F00D',
1742+
};
1743+
1744+
const expressions = [
1745+
{ expression: 'H * * * * *', expected: '5 * * * * *' },
1746+
{ expression: '* H * * * *', expected: '* 34 * * * *' },
1747+
{ expression: '* * H * * *', expected: '* * 15 * * *' },
1748+
{ expression: '* * * H * *', expected: '* * * 12 * *' },
1749+
{ expression: '* * * * H *', expected: '* * * * 8 *' },
1750+
{ expression: '* * * * * H', expected: '* * * * * 0' },
1751+
{ expression: 'H H * * * *', expected: '5 34 * * * *' },
1752+
{ expression: '* H H * * *', expected: '* 34 15 * * *' },
1753+
{ expression: '* * H H * *', expected: '* * 15 12 * *' },
1754+
{ expression: '* * * H H *', expected: '* * * 12 8 *' },
1755+
{ expression: '* * * * H H', expected: '* * * * 8 0' },
1756+
{ expression: 'H H H H H H', expected: '5 34 15 12 8 0' },
1757+
{ expression: 'H/5 * * * * *', expected: '5/5 * * * * *' },
1758+
{ expression: '* * * * * H#1', expected: '* * * * * 0' },
1759+
];
1760+
1761+
for (const { expression, expected } of expressions) {
1762+
expect(CronExpressionParser.parse(expression, options).stringify(true)).toBe(expected);
1763+
}
1764+
});
1765+
});
1766+
});
16871767
});

tests/random.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { seededRandom } from '../src/utils/random';
2+
3+
describe('seededRandom', () => {
4+
test('should return a random value each call when no seed is provided', () => {
5+
const rand = seededRandom();
6+
7+
const first = rand();
8+
expect(first).toEqual(expect.any(Number));
9+
10+
const second = rand();
11+
expect(second).toEqual(expect.any(Number));
12+
expect(second).not.toBe(first);
13+
14+
const rand2 = seededRandom();
15+
16+
const third = rand2();
17+
expect(third).toEqual(expect.any(Number));
18+
expect(third).not.toBe(first);
19+
20+
const fourth = rand2();
21+
expect(fourth).toEqual(expect.any(Number));
22+
expect(fourth).not.toBe(third);
23+
expect(fourth).not.toBe(second);
24+
});
25+
26+
test('should return the same value each ordered call when a seed is provided', () => {
27+
const rand = seededRandom('F00D');
28+
29+
expect(Math.floor(rand() * 100_000)).toBe(8440);
30+
expect(Math.floor(rand() * 100_000)).toBe(57228);
31+
expect(Math.floor(rand() * 100_000)).toBe(66401);
32+
expect(Math.floor(rand() * 100_000)).toBe(60998);
33+
});
34+
});

0 commit comments

Comments
 (0)