From 925c0558a0de41ef1eb2a2d3d1ee70cd841cb434 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 8 Aug 2026 04:51:58 +0800 Subject: [PATCH] fix(schedule): validate resumed cron rules live --- packages/schedule/tool-schedule/src/domain.ts | 34 ++++++++++++--- .../tool-schedule/tests/invariant.spec.ts | 42 +++++++++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/packages/schedule/tool-schedule/src/domain.ts b/packages/schedule/tool-schedule/src/domain.ts index c8b6402ed7..3013fc771b 100644 --- a/packages/schedule/tool-schedule/src/domain.ts +++ b/packages/schedule/tool-schedule/src/domain.ts @@ -872,23 +872,44 @@ function previousCronInstant( return latestCronInstantThrough(rule, timeZone, baseline, acceptedAt) } -/** Validate one newly appended Cron record against the current parser, ICU, and calendar adapter. */ -function validateLiveCronRecord(record: CronScheduleRecord): void { +/** Normalize a current calendar-validation failure for the package invariant. */ +function throwLiveCronValidationError(error: unknown): never { + if (error instanceof ScheduleLogError) throw error + /* v8 ignore next -- current parser and adapter failures are Error subclasses. */ + const detail = error instanceof Error ? error.message : String(error) + throw new ScheduleLogError(`live cron record is invalid: ${detail}`) +} + +/** Validate one Cron rule and zone against current grammar, frequency, and ICU data. */ +function validateLiveCronRule(record: CronScheduleRecord): { + readonly rule: ParsedCronRule + readonly timeZone: string +} { try { const rule = parseCronRule(record.cron) const timeZone = canonicalizeTimeZone(record.timeZone) if (timeZone !== record.timeZone) { throw new ScheduleLogError('live cron timeZone must use its current canonical IANA name') } + if (!rule.hasMatchingDate) { + throw new ScheduleLogError('live cron rule must have a matching Gregorian date') + } + return { rule, timeZone } + } catch (error: unknown) { + throwLiveCronValidationError(error) + } +} + +/** Validate one newly appended Cron record against the current calendar adapter. */ +function validateLiveCronRecord(record: CronScheduleRecord): void { + const { rule, timeZone } = validateLiveCronRule(record) + try { const target = Date.parse(record.scheduledAt) if (nextCronInstant(rule, timeZone, target - 60_000) !== target) { throw new ScheduleLogError('live cron scheduledAt must match its rule in the current time-zone data') } } catch (error: unknown) { - if (error instanceof ScheduleLogError) throw error - /* v8 ignore next -- current parser and adapter failures are Error subclasses. */ - const detail = error instanceof Error ? error.message : String(error) - throw new ScheduleLogError(`live cron record is invalid: ${detail}`) + throwLiveCronValidationError(error) } } @@ -1310,6 +1331,7 @@ export function validateLiveScheduleChange( const record = foldScheduleEvents(events, seedLength).active.find(candidate => candidate.id === change.id) /* v8 ignore next -- the preceding candidate fold requires calendar fields to target an active Cron record. */ if (record?.kind !== 'cron') return + validateLiveCronRule(record) const expected = resolveCronOccurrence(record, Date.parse(change.acceptedAt)) const nextScheduledAt = 'nextScheduledAt' in change ? change.nextScheduledAt : undefined if (change.occurrenceAt !== expected.occurrenceAt || nextScheduledAt !== expected.nextScheduledAt) { diff --git a/packages/schedule/tool-schedule/tests/invariant.spec.ts b/packages/schedule/tool-schedule/tests/invariant.spec.ts index fab523e617..6f9bf7b64e 100644 --- a/packages/schedule/tool-schedule/tests/invariant.spec.ts +++ b/packages/schedule/tool-schedule/tests/invariant.spec.ts @@ -143,6 +143,48 @@ describe('Schedule package invariant', () => { }, 0)], }) const fiber = await ctx.plugin(scheduleInvariant) + const invalidLiveRules = [ + { + id: 'schedule-historical-fast-cron', + cron: '* * * * *', + scheduledAt: '2026-08-06T12:00:00.000Z', + occurrenceAt: '2026-08-06T12:01:00.000Z', + acceptedAt: '2026-08-06T12:01:00.000Z', + nextScheduledAt: '2026-08-06T12:02:00.000Z', + }, + { + id: 'schedule-historical-impossible-cron', + cron: '0 0 31 2 *', + scheduledAt: '2026-02-01T00:00:00.000Z', + occurrenceAt: '2026-02-01T00:00:00.000Z', + acceptedAt: '2026-02-01T00:00:00.000Z', + nextScheduledAt: undefined, + }, + ] as const + for (const invalid of invalidLiveRules) { + const replay = ctx.sessions.create(SessionId(invalid.id), { + seed: [event({ + version: 1, + operation: 'create', + schedule: { + id: invalid.id, + kind: 'cron', + prompt: 'historical rule', + cron: invalid.cron, + timeZone: 'UTC', + scheduledAt: invalid.scheduledAt, + }, + }, 0)], + }) + expect(() => replay.append('schedule/change', { + version: 1, + operation: 'dispatch', + id: ScheduleId(invalid.id), + occurrenceAt: invalid.occurrenceAt, + acceptedAt: invalid.acceptedAt, + ...(invalid.nextScheduledAt === undefined ? {} : { nextScheduledAt: invalid.nextScheduledAt }), + })).toThrow(InvariantError) + } await fiber.dispose() await ctx.fiber.dispose() })