Run cron schedules when either restricted day field matches (#8611)

* fix(schedules): match either restricted cron day field

* refactor(schedules): name the combined day match conditions

Keep wildcard days conjunctive so weekday-only and date-only schedules
retain their meaning, while restricted day fields follow cron's union
rule. Preserve lexical wildcard identity rather than inferring it from
allowed values, since an explicit full range is still restricted.

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-11-03 02:52:16 +00:00
committed by GitHub
co-authored by 见安
parent 3857ed3365
commit a296bf3502
3 changed files with 40 additions and 2 deletions
@@ -5,6 +5,7 @@ interface CronFieldBounds {
}
export interface CronFieldMatcher {
isWildcard: boolean;
matches(value: number): boolean;
}
@@ -101,6 +102,7 @@ function parseField(source: string, bounds: CronFieldBounds): CronFieldMatcher {
}
return {
isWildcard: trimmed.startsWith("*"),
matches(value: number): boolean {
return allowed.has(value);
},
@@ -20,6 +20,37 @@ describe("schedule cron cadence", () => {
expect(next.toISOString()).toBe("2026-01-05T09:15:00.000Z");
});
test("matches either restricted day field", () => {
const next = computeNextRunAt(
{ type: "cron", expression: "0 9 1 * 1" },
new Date("2026-10-17T00:00:00.000Z"),
);
expect(next.toISOString()).toBe("2026-10-19T09:00:00.000Z");
});
test.each([
["0 9 1 * 1", "2026-10-30T10:00:00.000Z", "2026-11-01T09:00:00.000Z"],
["0 9 1 * *", "2026-10-17T00:00:00.000Z", "2026-11-01T09:00:00.000Z"],
["0 9 * * 1", "2026-10-17T00:00:00.000Z", "2026-10-19T09:00:00.000Z"],
["0 9 */2 * 1", "2026-10-19T09:00:00.000Z", "2026-11-09T09:00:00.000Z"],
["0 9 1-31 * 1", "2026-10-17T00:00:00.000Z", "2026-10-17T09:00:00.000Z"],
["0 9 1,15 * 5", "2026-10-17T00:00:00.000Z", "2026-10-23T09:00:00.000Z"],
])("honors day-field semantics for %s", (expression, after, expected) => {
const next = computeNextRunAt({ type: "cron", expression }, new Date(after));
expect(next.toISOString()).toBe(expected);
});
test("matches either restricted day field in the schedule's time zone", () => {
const next = computeNextRunAt(
{ type: "cron", expression: "0 9 1 * 1", timezone: "America/New_York" },
new Date("2026-10-17T00:00:00.000Z"),
);
expect(next.toISOString()).toBe("2026-10-19T13:00:00.000Z");
});
test("computes timezone cron matches at the requested wall-clock time", () => {
const winter = computeNextRunAt(
{ type: "cron", expression: "0 9 * * 1-5", timezone: "America/New_York" },
+7 -2
View File
@@ -99,19 +99,24 @@ export function computeNextRunAt(cadence: ScheduleCadence, after: Date): Date {
}
const cron = parseCronExpression(cadence.expression);
const hasWildcardDay = cron.dayOfMonth.isWildcard || cron.dayOfWeek.isWildcard;
const readDateParts = createCronDatePartsReader(cadence.timezone);
const limit = 366 * 24 * 60;
let cursor = startOfNextMinute(after);
for (let index = 0; index < limit; index += 1) {
const { minute, hour, dayOfMonth, month, dayOfWeek } = readDateParts(cursor);
const dayOfMonthMatches = cron.dayOfMonth.matches(dayOfMonth);
const dayOfWeekMatches = cron.dayOfWeek.matches(dayOfWeek);
const bothDaysMatch = dayOfMonthMatches && dayOfWeekMatches;
const eitherDayMatches = dayOfMonthMatches || dayOfWeekMatches;
const dayMatches = hasWildcardDay ? bothDaysMatch : eitherDayMatches;
if (
cron.minute.matches(minute) &&
cron.hour.matches(hour) &&
cron.dayOfMonth.matches(dayOfMonth) &&
cron.month.matches(month) &&
cron.dayOfWeek.matches(dayOfWeek)
dayMatches
) {
return cursor;
}