From a296bf3502eb363539b0f9aad9e5a8064dda7a69 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:52:16 +0000 Subject: [PATCH] Run cron schedules when either restricted day field matches (#8611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- .../protocol/src/schedule/cron-expression.ts | 2 ++ .../server/src/server/schedule/cron.test.ts | 31 +++++++++++++++++++ packages/server/src/server/schedule/cron.ts | 9 ++++-- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/protocol/src/schedule/cron-expression.ts b/packages/protocol/src/schedule/cron-expression.ts index cc93652afa..5cfffbbb1b 100644 --- a/packages/protocol/src/schedule/cron-expression.ts +++ b/packages/protocol/src/schedule/cron-expression.ts @@ -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); }, diff --git a/packages/server/src/server/schedule/cron.test.ts b/packages/server/src/server/schedule/cron.test.ts index 9d4172ff27..da8d9760ed 100644 --- a/packages/server/src/server/schedule/cron.test.ts +++ b/packages/server/src/server/schedule/cron.test.ts @@ -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" }, diff --git a/packages/server/src/server/schedule/cron.ts b/packages/server/src/server/schedule/cron.ts index 21491ff065..972289322d 100644 --- a/packages/server/src/server/schedule/cron.ts +++ b/packages/server/src/server/schedule/cron.ts @@ -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; }