Skip to content

Commit aa1ee7d

Browse files
authored
Hashed values (H) ranges and steps support (#382)
* Add support for hashed (H) ranges and steps in cron expressions * Update README.md * Add benchmark cases for hashed syntax
1 parent 3970e52 commit aa1ee7d

File tree

7 files changed

+130
-12
lines changed

7 files changed

+130
-12
lines changed

README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ The `CronFieldCollection.from` method accepts either CronField instances or raw
294294

295295
### Hash support
296296

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).
297+
The library supports 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).
298298

299299
This jitter allows to spread the load when it comes to job scheduling. This feature is inspired by Jenkins's cron syntax.
300300

@@ -310,9 +310,18 @@ const interval = CronExpressionParser.parse('30 H * * *');
310310
// At every minutes of <randomized> hour at <randomized> second everyday.
311311
const interval = CronExpressionParser.parse('H * H * * *');
312312

313-
// At every 5th minute from <randomized> through 59 everyday.
313+
// At every 5th minute starting from a random offset.
314+
// For example, if the random offset is 3, it will run at minutes 3, 8, 13, 18, etc.
314315
const interval = CronExpressionParser.parse('H/5 * * * *');
315316

317+
// At a random minute within the range 0-10 everyday.
318+
const interval = CronExpressionParser.parse('H(0-10) * * * *');
319+
320+
// At every 5th minute starting from a random offset within the range 0-4.
321+
// For example, if the random offset is 2, it will run at minutes 2, 7, 12, 17, etc.
322+
// The random offset is constrained to be less than the step value.
323+
const interval = CronExpressionParser.parse('H(0-29)/5 * * * *');
324+
316325
// At every minute of the third <randomized> day of the month
317326
const interval = CronExpressionParser.parse('* * * * H#3');
318327
```

benchmarks/benchmark-inputs.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,10 @@ export const benchmarkInputs: BenchmarkInput[] = [
1616
{ pattern: '0 0 0 * * 4,6L', description: 'At midnight on every Thursday and last Saturday of every month' },
1717
{ pattern: '0 0 0 * * 1L,5L', description: 'At midnight on the last Monday and last Friday of every month' },
1818
{ pattern: '0 0 6-20/2,L 2 *', description: 'At midnight on every 2nd hour between 6-20 and last day in February' },
19+
{ pattern: '0 H * * *', description: 'Every hour on every day of every month' },
20+
{ pattern: '0 H/3 * * *', description: 'Every 3 hours on every day of every month' },
21+
{
22+
pattern: 'H H H(9-20)/3 1-11 *',
23+
description: 'Every 3 hours between 9am and 8pm on the 1st through 11th months',
24+
},
1925
];

src/CronExpressionParser.ts

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -205,11 +205,89 @@ export class CronExpressionParser {
205205
throw new Error(`Invalid characters, got value: ${value}`);
206206
}
207207

208-
// Replace '*' and '?'
209-
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)));
212-
return CronExpressionParser.#parseSequence(field, value, constraints);
208+
value = this.#parseWildcard(value, constraints);
209+
value = this.#parseHashed(value, constraints, rand);
210+
return this.#parseSequence(field, value, constraints);
211+
}
212+
213+
/**
214+
* Parse a wildcard from a cron expression.
215+
* @param {string} value - The value to parse.
216+
* @param {CronConstraints} constraints - The constraints for the field.
217+
* @private
218+
*/
219+
static #parseWildcard(value: string, constraints: CronConstraints): string {
220+
return value.replace(/[*?]/g, constraints.min + '-' + constraints.max);
221+
}
222+
223+
/**
224+
* Parse a hashed value from a cron expression.
225+
* @param {string} value - The value to parse.
226+
* @param {CronConstraints} constraints - The constraints for the field.
227+
* @param {PRNG} rand - The random number generator to use.
228+
* @private
229+
*/
230+
static #parseHashed(value: string, constraints: CronConstraints, rand: PRNG): string {
231+
const randomValue = rand();
232+
return value.replace(/H(?:\((\d+)-(\d+)\))?(?:\/(\d+))?/g, (_, min, max, step) => {
233+
// H(range)/step
234+
if (min && max && step) {
235+
const minNum = parseInt(min, 10);
236+
const maxNum = parseInt(max, 10);
237+
const stepNum = parseInt(step, 10);
238+
239+
if (minNum > maxNum) {
240+
throw new Error(`Invalid range: ${minNum}-${maxNum}, min > max`);
241+
}
242+
if (stepNum <= 0) {
243+
throw new Error(`Invalid step: ${stepNum}, must be positive`);
244+
}
245+
246+
const minStart = Math.max(minNum, constraints.min);
247+
const offset = Math.floor(randomValue * stepNum);
248+
const values = [];
249+
for (let i = Math.floor(minStart / stepNum) * stepNum + offset; i <= maxNum; i += stepNum) {
250+
if (i >= minStart) {
251+
values.push(i);
252+
}
253+
}
254+
255+
return values.join(',');
256+
}
257+
// H(range)
258+
else if (min && max) {
259+
const minNum = parseInt(min, 10);
260+
const maxNum = parseInt(max, 10);
261+
262+
if (minNum > maxNum) {
263+
throw new Error(`Invalid range: ${minNum}-${maxNum}, min > max`);
264+
}
265+
return String(Math.floor(randomValue * (maxNum - minNum + 1)) + minNum);
266+
}
267+
// H/step
268+
else if (step) {
269+
const stepNum = parseInt(step, 10);
270+
271+
// Validate step
272+
if (stepNum <= 0) {
273+
throw new Error(`Invalid step: ${stepNum}, must be positive`);
274+
}
275+
276+
const offset = Math.floor(randomValue * stepNum);
277+
const values = [];
278+
for (let i = Math.floor(constraints.min / stepNum) * stepNum + offset; i <= constraints.max; i += stepNum) {
279+
if (i >= constraints.min) {
280+
values.push(i);
281+
}
282+
}
283+
284+
return values.join(',');
285+
}
286+
// H
287+
else {
288+
return String(Math.floor(randomValue * (constraints.max - constraints.min + 1) + constraints.min));
289+
}
290+
});
213291
}
214292

215293
/**

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 /^[?,*\dLH/-]+$/;
26+
return /^[?,*\dLH/-]+$|^.*H\(\d+-\d+\)\/\d+.*$|^.*H\(\d+-\d+\).*$|^.*H\/\d+.*$/;
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 /^[?,*\dLH#/-]+$/;
27+
return /^[?,*\dLH#/-]+$|^.*H\(\d+-\d+\)\/\d+.*$|^.*H\(\d+-\d+\).*$|^.*H\/\d+.*$/;
2828
}
2929

3030
/**

src/fields/CronField.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export abstract class CronField {
6666
* Returns the regular expression used to validate this field.
6767
*/
6868
static get validChars(): RegExp {
69-
return /^[?,*\dH/-]+$/;
69+
return /^[?,*\dH/-]+$|^.*H\(\d+-\d+\)\/\d+.*$|^.*H\(\d+-\d+\).*$|^.*H\/\d+.*$/;
7070
}
7171

7272
/**

tests/CronExpressionParser.test.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1685,7 +1685,29 @@ describe('CronExpressionParser', () => {
16851685
});
16861686
});
16871687

1688-
describe('test expressions using the hash extension syntax', () => {
1688+
describe('test expressions using hashed extension syntax', () => {
1689+
describe('pattern validation', () => {
1690+
// Test invalid step value for H/step pattern
1691+
test('throws error for invalid step in H/step pattern', () => {
1692+
expect(() => CronExpressionParser.parse('H/0 * * * *')).toThrow('Invalid step: 0, must be positive');
1693+
});
1694+
1695+
// Test invalid range for H(range) pattern
1696+
test('throws error for invalid range in H(range) pattern', () => {
1697+
expect(() => CronExpressionParser.parse('H(10-5) * * * *')).toThrow('Invalid range: 10-5, min > max');
1698+
});
1699+
1700+
// Test invalid range for H(range)/step pattern
1701+
test('throws error for invalid range in H(range)/step pattern', () => {
1702+
expect(() => CronExpressionParser.parse('H(10-5)/2 * * * *')).toThrow('Invalid range: 10-5, min > max');
1703+
});
1704+
1705+
// Test invalid step for H(range)/step pattern
1706+
test('throws error for invalid step in H(range)/step pattern', () => {
1707+
expect(() => CronExpressionParser.parse('H(1-10)/0 * * * *')).toThrow('Invalid step: 0, must be positive');
1708+
});
1709+
});
1710+
16891711
// Not having a seed is making tests less useful
16901712
describe('without a custom seed', () => {
16911713
test('parses expressions using H on all fields', () => {
@@ -1754,7 +1776,10 @@ describe('CronExpressionParser', () => {
17541776
{ expression: '* * * H H *', expected: '* * * 12 8 *' },
17551777
{ expression: '* * * * H H', expected: '* * * * 8 0' },
17561778
{ expression: 'H H H H H H', expected: '5 34 15 12 8 0' },
1757-
{ expression: 'H/5 * * * * *', expected: '5/5 * * * * *' },
1779+
{ expression: 'H/5 * * * * *', expected: '*/5 * * * * *' },
1780+
{ expression: 'H(10-20) * * * *', expected: '0 16 * * * *' },
1781+
{ expression: 'H(0-29)/10 * * * *', expected: '0 5,15,25 * * * *' },
1782+
{ expression: '* H H(9-20)/3 * * 1-5', expected: '* 34 10-19/3 * * 1-5' },
17581783
{ expression: '* * * * * H#1', expected: '* * * * * 0#1' },
17591784
];
17601785

0 commit comments

Comments
 (0)