Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/job-cron-steps.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Job schedules: a cron step on a single value (`N/S`) now runs from N to the end of the field, as in Vixie cron and cronie. `5/1` in the minute field was minute 5 alone and is now `5-59`. In day-of-week, `N/S` now runs through `7` (Sunday), so `1/2` adds Sunday, and `7/2` (Sunday) is accepted instead of refused as a reversed range. `5/15`, `N-M/S` and `*/S` are unchanged.
11 changes: 7 additions & 4 deletions vendor/wheels/JobCron.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,8 @@ component {
$invalid("'#arguments.part#' in the #arguments.label# field uses a Quartz extension (L, W, ## or ?), which isn't supported.");
}
local.step = 1;
if (Find("/", local.text)) {
local.stepped = Find("/", local.text) > 0;
if (local.stepped) {
local.stepText = ListLast(local.text, "/");
local.text = ListFirst(local.text, "/");
if (!REFind("^[0-9]+$", local.stepText) || Val(local.stepText) < 1) {
Expand All @@ -201,9 +202,11 @@ component {
}
local.first = $value(local.bounds[1], arguments.low, arguments.high, arguments.label, arguments.names);
local.last = ArrayLen(local.bounds) == 2 ? $value(local.bounds[2], arguments.low, arguments.high, arguments.label, arguments.names) : local.first;
if (ArrayLen(local.bounds) == 1 && local.step > 1) {
// "5/15" means from 5 to the end of the range, every 15.
local.last = arguments.high == 7 ? 6 : arguments.high;
if (ArrayLen(local.bounds) == 1 && local.stepped) {
// Vixie cron / cronie: "N/S" runs from N to the field's maximum, every S, so "5/15" is
// 5,20,35,50 and "5/1" is 5-59. Day-of-week runs to 7 (Sunday again): "7/2" is Sunday,
// and "1/2" is Monday, Wednesday, Friday and Sunday.
local.last = arguments.high;
}
if (local.last < local.first) {
$invalid("'#arguments.part#' is a reversed range in the #arguments.label# field.");
Expand Down
48 changes: 48 additions & 0 deletions vendor/wheels/tests/specs/jobs/JobSchedulerSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,32 @@ component extends="wheels.WheelsTest" {
expect(c.isoUtc(c.nextCron(cron, c.msFromIsoUtc("2026-10-17T10:00Z")))).toBe("2026-10-19T09:00:00Z");
});

it("runs N/S from N to the end of the field, as Vixie cron and cronie do", function() {
var c = new wheels.JobCron();
expect($matching(c.parse("5/15 * * * *").minutes, 0)).toBe("5,20,35,50");
expect($matching(c.parse("5/1 * * * *").minutes, 0)).toBe(ArrayToList($sequence(5, 59)));
expect($matching(c.parse("0 22/1 * * *").hours, 0)).toBe("22,23");
expect($matching(c.parse("0 0 30/1 * *").days, 1)).toBe("30,31");
expect($matching(c.parse("0 0 1 11/1 *").months, 1)).toBe("11,12");
// Unchanged: a bare value, a range with a step, and */S.
expect($matching(c.parse("5 * * * *").minutes, 0)).toBe("5");
expect($matching(c.parse("5-20/5 * * * *").minutes, 0)).toBe("5,10,15,20");
expect($matching(c.parse("*/15 * * * *").minutes, 0)).toBe("0,15,30,45");
// The next slot follows: from 10:21, "5/15" next fires at 10:35.
expect(c.isoUtc(c.nextCron(c.parse("5/15 * * * *"), c.msFromIsoUtc("2026-10-12T10:21Z")))).toBe("2026-10-12T10:35:00Z");
});

it("runs a day-of-week N/S through 7, which is Sunday again", function() {
var c = new wheels.JobCron();
// The weekday table is Sunday (0) to Saturday (6).
expect($matching(c.parse("0 9 * * 7/2").weekdays, 0)).toBe("0");
expect($matching(c.parse("0 9 * * 1/2").weekdays, 0)).toBe("0,1,3,5");
expect($matching(c.parse("0 9 * * MON/2").weekdays, 0)).toBe("0,1,3,5");
expect($matching(c.parse("0 9 * * */2").weekdays, 0)).toBe("0,2,4,6");
// 2026-10-12 is a Monday: "7/2" next fires on Sunday the 18th.
expect(c.isoUtc(c.nextCron(c.parse("0 9 * * 7/2"), c.msFromIsoUtc("2026-10-12T00:00Z")))).toBe("2026-10-18T09:00:00Z");
});

it("matches day-of-month OR day-of-week when both are restricted", function() {
var c = new wheels.JobCron();
// The 13th, or any Friday: from Sunday 2026-02-01 the first match is Friday the 6th.
Expand Down Expand Up @@ -396,6 +422,28 @@ component extends="wheels.WheelsTest" {
return rv;
}

/**
* The values a JobCron match table allows, as a list. `low` is the field's first value: 0 for
* minutes, hours and weekdays, 1 for days and months.
*/
private string function $matching(required array table, required numeric low) {
var values = [];
for (var i = 1; i <= ArrayLen(arguments.table); i++) {
if (arguments.table[i]) {
ArrayAppend(values, i - 1 + arguments.low);
}
}
return ArrayToList(values);
}

private array function $sequence(required numeric first, required numeric last) {
var values = [];
for (var i = arguments.first; i <= arguments.last; i++) {
ArrayAppend(values, i);
}
return values;
}

private numeric function $jobCount(required string queue) {
return Val(queryExecute(
"SELECT COUNT(*) AS cnt FROM wheels_jobs WHERE queue = :queue",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ schedule("health-check")
```

- **`.job(class)`** — the job class under `app/jobs/`, as for `enqueue`.
- **`.cron(expression)`** — a standard 5-field cron expression (`minute hour day-of-month month day-of-week`), read in the schedule's time zone. Lists, ranges, steps (`*/5`), and names (`MON`, `JAN`) work, as do the `@hourly` … `@yearly` aliases. Seconds and Quartz extensions are refused.
- **`.cron(expression)`** — a standard 5-field cron expression (`minute hour day-of-month month day-of-week`), read in the schedule's time zone. Lists, ranges, steps (`*/5`), and names (`MON`, `JAN`) work, as do the `@hourly` … `@yearly` aliases. A step on a single value runs to the end of the field, as in Vixie cron and cronie: `5/15` in the minute field is `5,20,35,50`, `5/1` is `5-59`, and in day-of-week (`0`-`7`, both Sunday) `1/2` is Monday, Wednesday, Friday and Sunday. Seconds and Quartz extensions are refused.
- **`.every(count, "minutes" | "hours")`** — a fixed interval, aligned to the epoch. For a daily or longer cadence, use `.cron()` instead.
- **`.timezone(zone)`** — the IANA zone `.cron()` is read in (default `UTC`), e.g. `Europe/London`.
- **`.catchUp(mode)`**, **`.catchUpWindow(seconds)`**, **`.queue()`**, **`.priority()`**, **`.data(struct)`**, **`.enabled(boolean)`** — tune catch-up (below), and set the same options you'd pass to `enqueue`.
Expand Down
Loading