diff --git a/changelog.d/job-cron-steps.changed.md b/changelog.d/job-cron-steps.changed.md new file mode 100644 index 0000000000..805f8d7234 --- /dev/null +++ b/changelog.d/job-cron-steps.changed.md @@ -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. diff --git a/vendor/wheels/JobCron.cfc b/vendor/wheels/JobCron.cfc index 547b58149f..9b8805c7c6 100644 --- a/vendor/wheels/JobCron.cfc +++ b/vendor/wheels/JobCron.cfc @@ -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) { @@ -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."); diff --git a/vendor/wheels/tests/specs/jobs/JobSchedulerSpec.cfc b/vendor/wheels/tests/specs/jobs/JobSchedulerSpec.cfc index d3feb045bd..cfd777d1d3 100644 --- a/vendor/wheels/tests/specs/jobs/JobSchedulerSpec.cfc +++ b/vendor/wheels/tests/specs/jobs/JobSchedulerSpec.cfc @@ -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. @@ -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", diff --git a/web/sites/guides/src/content/docs/v4-2-0/digging-deeper/running-jobs-on-multiple-servers.mdx b/web/sites/guides/src/content/docs/v4-2-0/digging-deeper/running-jobs-on-multiple-servers.mdx index e7c5c0f531..c5c62714d4 100644 --- a/web/sites/guides/src/content/docs/v4-2-0/digging-deeper/running-jobs-on-multiple-servers.mdx +++ b/web/sites/guides/src/content/docs/v4-2-0/digging-deeper/running-jobs-on-multiple-servers.mdx @@ -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`.