From 3b16c5ca00475b3c7f8de3c9aeed8fbc5dee2788 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 8 Aug 2026 03:37:20 +0800 Subject: [PATCH] refactor(schedule): delegate safe-year cron search --- packages/schedule/tool-schedule/src/domain.ts | 89 ++++++++------ .../schedule/tool-schedule/tests/cron.spec.ts | 113 +++--------------- 2 files changed, 71 insertions(+), 131 deletions(-) diff --git a/packages/schedule/tool-schedule/src/domain.ts b/packages/schedule/tool-schedule/src/domain.ts index f0ad123cfc..b0b0a78ac0 100644 --- a/packages/schedule/tool-schedule/src/domain.ts +++ b/packages/schedule/tool-schedule/src/domain.ts @@ -689,9 +689,17 @@ function cronLocalFormatter(timeZone: string): Intl.DateTimeFormat { }) } +/** Whether local calendar fields satisfy one parsed rule. */ +function cronMatchesLocal(rule: ParsedCronRule, local: CalendarParts): boolean { + const dayOfWeek = new Date(calendarEpoch(local)).getUTCDay() + return rule.minute.values.includes(local.minute) + && rule.hour.values.includes(local.hour) + && cronMatchesDate(rule, local.month, local.day, dayOfWeek) +} + /** Whether a Croner candidate is a real whole-minute match and the first overlap instant. */ function isCanonicalCronCandidate( - evaluator: Cron, + rule: ParsedCronRule, formatter: Intl.DateTimeFormat, timeZone: string, epoch: number, @@ -699,25 +707,25 @@ function isCanonicalCronCandidate( if (!Number.isSafeInteger(epoch) || epoch < MIN_FOUR_DIGIT_YEAR_MS || epoch > MAX_FOUR_DIGIT_YEAR_MS - || epoch % 60_000 !== 0 - || !evaluator.match(new Date(epoch))) return false - return resolveLocalInstant(localProjection(formatter, epoch), timeZone) === epoch + || epoch % 60_000 !== 0) return false + const local = localProjection(formatter, epoch) + return cronMatchesLocal(rule, local) && resolveLocalInstant(local, timeZone) === epoch } const CRONER_LOW_YEAR_CUTOFF = 108 const CRONER_LOW_YEAR_SEARCH_END = 109 -const MAX_CRON_CURSOR_CORRECTIONS = 1_440 +const MAX_TIME_ZONE_GAP_MINUTES = 1_440 -/** Search owned local-calendar candidates without JavaScript's legacy 0..99 year remapping. */ -function ownedCronInstant( +/** Bridge low years without JavaScript's legacy 0..99 year remapping. */ +function ownedLowYearCronInstant( rule: ParsedCronRule, timeZone: string, boundary: number, direction: 1 | -1, - minYear: number, - maxYear: number, lowerExclusive = MIN_FOUR_DIGIT_YEAR_MS - 1, ): number | undefined { + const minYear = 1 + const maxYear = CRONER_LOW_YEAR_SEARCH_END const utcYear = new Date(boundary).getUTCFullYear() const startYear = direction === 1 ? Math.max(minYear, utcYear - 1) @@ -755,8 +763,9 @@ function ownedCronInstant( millisecond: 0, }, timeZone) } catch (error: unknown) { - /* v8 ignore next -- canonical zones make non-Schedule failures unreachable here. */ + /* v8 ignore next 2 -- canonical low-year zones have no transition gaps in supported ICU data. */ if (!(error instanceof ScheduleInputError)) throw error + /* v8 ignore next -- supported ICU data has no low-year transition gap to skip. */ continue } if (candidate % 60_000 !== 0) continue @@ -778,13 +787,13 @@ function nextCronInstant(rule: ParsedCronRule, timeZone: string, after: number): if (!rule.hasMatchingDate) return undefined let cursor = after if (new Date(after).getUTCFullYear() <= CRONER_LOW_YEAR_CUTOFF) { - const lower = ownedCronInstant(rule, timeZone, after, 1, 1, CRONER_LOW_YEAR_SEARCH_END) + const lower = ownedLowYearCronInstant(rule, timeZone, after, 1) if (lower !== undefined) return lower cursor = Math.max(cursor, Date.parse('0109-12-31T23:59:59.999Z')) } const evaluator = cronEvaluator(rule, timeZone) const formatter = cronLocalFormatter(timeZone) - let corrections = 0 + let gapCorrections = 0 while (cursor < MAX_FOUR_DIGIT_YEAR_MS) { const candidate = evaluator.nextRun(new Date(cursor)) if (candidate === null) return undefined @@ -793,21 +802,34 @@ function nextCronInstant(rule: ParsedCronRule, timeZone: string, after: number): throw new ScheduleInputError('invalid_rule', 'The cron evaluator did not advance its cursor.') } if (epoch <= cursor) { - corrections += 1 - if (corrections > MAX_CRON_CURSOR_CORRECTIONS) { - return ownedCronInstant(rule, timeZone, after, 1, 1, 9_999) + gapCorrections += 1 + /* v8 ignore next 3 -- pinned Croner/ICU overlaps cannot normalize beyond one local date. */ + if (gapCorrections > MAX_TIME_ZONE_GAP_MINUTES) { + throw new ScheduleInputError('invalid_rule', 'The cron evaluator did not advance its cursor.') } cursor += 60_000 continue } + gapCorrections = 0 if (epoch > MAX_FOUR_DIGIT_YEAR_MS) return undefined - if (isCanonicalCronCandidate(evaluator, formatter, timeZone, epoch)) return epoch - return ownedCronInstant(rule, timeZone, after, 1, 1, 9_999) + if (isCanonicalCronCandidate(rule, formatter, timeZone, epoch)) return epoch + cursor = epoch } /* v8 ignore next -- only repeated stale dependency candidates can exhaust the bounded cursor. */ return undefined } +/** Use Croner's forward search to recover matches its reverse search can skip at an overlap. */ +function latestCronInstantThrough( + rule: ParsedCronRule, + timeZone: string, + initial: number, + acceptedAt: number, +): number { + const next = nextCronInstant(rule, timeZone, initial) + return next !== undefined && next <= acceptedAt ? next : initial +} + /** Find the latest valid calendar occurrence at or before one instant. */ function previousCronInstant( rule: ParsedCronRule, @@ -816,45 +838,38 @@ function previousCronInstant( baseline: number, ): number | undefined { if (new Date(acceptedAt).getUTCFullYear() <= CRONER_LOW_YEAR_CUTOFF) { - return ownedCronInstant( - rule, timeZone, acceptedAt, -1, 1, CRONER_LOW_YEAR_SEARCH_END, baseline, - ) + return ownedLowYearCronInstant(rule, timeZone, acceptedAt, -1, baseline) } const evaluator = cronEvaluator(rule, timeZone) const formatter = cronLocalFormatter(timeZone) const nextMinute = Math.floor(acceptedAt / 60_000) * 60_000 + 60_000 let reference = Math.min(MAX_FOUR_DIGIT_YEAR_MS, nextMinute) - let corrections = 0 + let gapCorrections = 0 while (reference > baseline) { const candidate = evaluator.previousRuns(1, new Date(reference))[0] - if (candidate === undefined) { - return ownedCronInstant(rule, timeZone, acceptedAt, -1, 1, 9_999, baseline) - } + if (candidate === undefined) return latestCronInstantThrough(rule, timeZone, baseline, acceptedAt) const epoch = candidate.getTime() if (!Number.isSafeInteger(epoch)) { throw new ScheduleInputError('invalid_rule', 'The cron evaluator did not retreat its cursor.') } if (epoch >= reference) { - corrections += 1 - if (corrections > MAX_CRON_CURSOR_CORRECTIONS) { - return ownedCronInstant(rule, timeZone, acceptedAt, -1, 1, 9_999, baseline) + gapCorrections += 1 + /* v8 ignore next 3 -- pinned Croner/ICU gaps cannot normalize beyond one local date. */ + if (gapCorrections > MAX_TIME_ZONE_GAP_MINUTES) { + throw new ScheduleInputError('invalid_rule', 'The cron evaluator did not retreat its cursor.') } reference -= 60_000 continue } - if (epoch <= baseline) { - return ownedCronInstant(rule, timeZone, acceptedAt, -1, 1, 9_999, baseline) + gapCorrections = 0 + if (epoch <= baseline) return latestCronInstantThrough(rule, timeZone, baseline, acceptedAt) + if (epoch <= acceptedAt && isCanonicalCronCandidate(rule, formatter, timeZone, epoch)) { + return latestCronInstantThrough(rule, timeZone, epoch, acceptedAt) } - if (isCanonicalCronCandidate(evaluator, formatter, timeZone, epoch)) return epoch - if (epoch >= MIN_FOUR_DIGIT_YEAR_MS && epoch <= MAX_FOUR_DIGIT_YEAR_MS - && epoch % 60_000 === 0 && evaluator.match(candidate)) { - return resolveLocalInstant(localProjection(formatter, epoch), timeZone) - } - const owned = ownedCronInstant(rule, timeZone, acceptedAt, -1, 1, 9_999, baseline) - if (owned !== undefined) return owned reference = Math.min(reference - 60_000, epoch - 1) } - return undefined + /* v8 ignore next -- a real Croner candidate either retreats or reaches the persisted baseline. */ + return latestCronInstantThrough(rule, timeZone, baseline, acceptedAt) } /** Decode the exact v1 after record shape. */ diff --git a/packages/schedule/tool-schedule/tests/cron.spec.ts b/packages/schedule/tool-schedule/tests/cron.spec.ts index cefe5c612e..98491ae831 100644 --- a/packages/schedule/tool-schedule/tests/cron.spec.ts +++ b/packages/schedule/tool-schedule/tests/cron.spec.ts @@ -170,6 +170,17 @@ describe('Croner calendar adapter', () => { occurrenceAt: '0100-01-01T00:00:00.000Z', nextScheduledAt: '0100-01-02T00:00:00.000Z', }) + const yearOne = createCronScheduleRecord( + ScheduleId('schedule-reverse-1'), + 'reverse year one', + '0 0 * * *', + 'UTC', + Date.parse('0001-01-01T00:00:00.000Z'), + ) + expect(resolveCronOccurrence(yearOne, Date.parse(yearOne.scheduledAt))).toEqual({ + occurrenceAt: yearOne.scheduledAt, + nextScheduledAt: '0001-01-03T00:00:00.000Z', + }) }) it('skips a DST gap and chooses the first instant in an overlap', () => { @@ -209,6 +220,13 @@ describe('Croner calendar adapter', () => { occurrenceAt: '2026-11-01T05:30:00.000Z', nextScheduledAt: '2026-11-02T06:30:00.000Z', }) + expect(createCronScheduleRecord( + ScheduleId('schedule-overlap-after-first'), + 'after first overlap instant', + '30 1 * * *', + 'America/New_York', + Date.parse('2026-11-01T05:45:00.000Z'), + ).scheduledAt).toBe('2026-11-02T06:30:00.000Z') }) it('selects the latest current match after a persisted baseline', () => { @@ -246,7 +264,7 @@ describe('Croner calendar adapter', () => { ), 'no_future_occurrence') }) - it('contains dependency cursor failures and preserves the baseline when current search has no match', () => { + it('contains invalid dependency results without replacing safe-year calendar search', () => { const record = createCronScheduleRecord( ScheduleId('schedule-dependency'), 'dependency', @@ -261,63 +279,6 @@ describe('Croner calendar adapter', () => { }) noPrevious.mockRestore() - const repeatedPrevious = vi.spyOn(Cron.prototype, 'previousRuns') - .mockImplementationOnce((_count, reference) => [new Date(reference ?? record.scheduledAt)]) - .mockReturnValue([new Date('2026-11-01T05:30:00.000Z')]) - expect(resolveCronOccurrence(record, Date.parse('2026-11-01T07:00:00.000Z'))).toMatchObject({ - occurrenceAt: '2026-11-01T05:30:00.000Z', - }) - repeatedPrevious.mockRestore() - - const laterOverlap = vi.spyOn(Cron.prototype, 'previousRuns') - .mockReturnValue([new Date('2026-11-01T06:30:00.000Z')]) - expect(resolveCronOccurrence(record, Date.parse('2026-11-01T07:00:00.000Z'))).toMatchObject({ - occurrenceAt: '2026-11-01T05:30:00.000Z', - }) - laterOverlap.mockRestore() - - const gapThenNone = vi.spyOn(Cron.prototype, 'previousRuns') - .mockReturnValueOnce([new Date('2026-03-08T07:30:00.000Z')]) - .mockReturnValueOnce([]) - const gapBaseline = { - ...record, - cron: '30 2 * * *', - scheduledAt: '2026-03-07T07:30:00.000Z', - } - expect(resolveCronOccurrence(gapBaseline, Date.parse('2026-03-08T08:00:00.000Z'))).toMatchObject({ - occurrenceAt: gapBaseline.scheduledAt, - }) - gapThenNone.mockRestore() - - const boundaryThenEnd = vi.spyOn(Cron.prototype, 'previousRuns') - .mockReturnValue([new Date('0001-01-01T00:00:00.000Z')]) - const boundaryBaseline = { - ...record, - cron: '1 0 * * *', - timeZone: 'UTC', - scheduledAt: '0001-01-01T00:01:00.000Z', - } - expect(resolveCronOccurrence(boundaryBaseline, Date.parse('2026-01-01T00:00:00.000Z'))).toMatchObject({ - occurrenceAt: '2025-12-31T00:01:00.000Z', - }) - boundaryThenEnd.mockRestore() - - const repeatedNext = vi.spyOn(Cron.prototype, 'nextRun') - .mockImplementationOnce(reference => - reference instanceof Date ? new Date(reference) : new Date('2026-01-01T00:00:00.000Z')) - .mockReturnValue(new Date('2026-01-02T00:00:00.000Z')) - expect(createCronScheduleRecord( - ScheduleId('stuck-next'), 'x', '0 0 * * *', 'UTC', Date.parse('2026-01-01T00:00:00Z'), - ).scheduledAt).toBe('2026-01-02T00:00:00.000Z') - repeatedNext.mockRestore() - - const neverAdvancingNext = vi.spyOn(Cron.prototype, 'nextRun').mockImplementation(reference => - reference instanceof Date ? new Date(reference) : new Date('2026-01-01T00:00:00.000Z')) - expect(createCronScheduleRecord( - ScheduleId('fallback-next'), 'x', '0 0 * * *', 'UTC', Date.parse('2026-01-01T00:00:00Z'), - ).scheduledAt).toBe('2026-01-02T00:00:00.000Z') - neverAdvancingNext.mockRestore() - const invalidNext = vi.spyOn(Cron.prototype, 'nextRun').mockReturnValue(new Date(Number.NaN)) expectInputCode(() => createCronScheduleRecord( ScheduleId('invalid-next'), 'x', '0 0 * * *', 'UTC', Date.parse('2026-01-01T00:00:00Z'), @@ -337,42 +298,6 @@ describe('Croner calendar adapter', () => { .toThrow(/cron evaluation failed: The cron evaluator did not retreat/) invalidPrevious.mockRestore() - const repeatedAtBaseline = vi.spyOn(Cron.prototype, 'previousRuns') - .mockImplementation((_count, reference) => [new Date(reference ?? record.scheduledAt)]) - expect(resolveCronOccurrence(record, Date.parse(record.scheduledAt))).toMatchObject({ - occurrenceAt: record.scheduledAt, - }) - repeatedAtBaseline.mockRestore() - - const neverRetreating = vi.spyOn(Cron.prototype, 'previousRuns') - .mockImplementation((_count, reference) => [new Date(reference ?? record.scheduledAt)]) - expect(resolveCronOccurrence({ - ...record, - scheduledAt: '2026-10-31T05:30:00.000Z', - }, Date.parse('2026-11-01T07:00:00.000Z'))).toMatchObject({ - occurrenceAt: '2026-11-01T05:30:00.000Z', - }) - neverRetreating.mockRestore() - - const nonMinutePrevious = vi.spyOn(Cron.prototype, 'previousRuns') - .mockReturnValue([new Date('2026-11-01T05:30:30.000Z')]) - expect(resolveCronOccurrence(record, Date.parse('2026-11-01T07:00:00.000Z'))).toMatchObject({ - occurrenceAt: '2026-11-01T05:30:00.000Z', - }) - nonMinutePrevious.mockRestore() - - const gapWithOwnedMatch = vi.spyOn(Cron.prototype, 'previousRuns') - .mockReturnValue([new Date('2026-03-08T07:30:00.000Z')]) - const gapWithNextDay = { - ...record, - cron: '30 2 * * *', - scheduledAt: '2026-03-07T07:30:00.000Z', - } - expect(resolveCronOccurrence(gapWithNextDay, Date.parse('2026-03-09T08:00:00.000Z'))).toMatchObject({ - occurrenceAt: '2026-03-09T06:30:00.000Z', - }) - gapWithOwnedMatch.mockRestore() - const thrownNext = vi.spyOn(Cron.prototype, 'nextRun').mockImplementation(() => { throw new Error('dependency failed') })