fix(schedule): validate resumed cron rules live

This commit is contained in:
pku-xht
2026-08-08 04:51:58 +08:00
committed by Tianyi Cui
parent c981eaa1d5
commit 925c0558a0
2 changed files with 70 additions and 6 deletions
+28 -6
View File
@@ -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) {
@@ -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()
})