fix(schedule): validate resumed cron rules live
This commit is contained in:
@@ -872,23 +872,44 @@ function previousCronInstant(
|
|||||||
return latestCronInstantThrough(rule, timeZone, baseline, acceptedAt)
|
return latestCronInstantThrough(rule, timeZone, baseline, acceptedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate one newly appended Cron record against the current parser, ICU, and calendar adapter. */
|
/** Normalize a current calendar-validation failure for the package invariant. */
|
||||||
function validateLiveCronRecord(record: CronScheduleRecord): void {
|
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 {
|
try {
|
||||||
const rule = parseCronRule(record.cron)
|
const rule = parseCronRule(record.cron)
|
||||||
const timeZone = canonicalizeTimeZone(record.timeZone)
|
const timeZone = canonicalizeTimeZone(record.timeZone)
|
||||||
if (timeZone !== record.timeZone) {
|
if (timeZone !== record.timeZone) {
|
||||||
throw new ScheduleLogError('live cron timeZone must use its current canonical IANA name')
|
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)
|
const target = Date.parse(record.scheduledAt)
|
||||||
if (nextCronInstant(rule, timeZone, target - 60_000) !== target) {
|
if (nextCronInstant(rule, timeZone, target - 60_000) !== target) {
|
||||||
throw new ScheduleLogError('live cron scheduledAt must match its rule in the current time-zone data')
|
throw new ScheduleLogError('live cron scheduledAt must match its rule in the current time-zone data')
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof ScheduleLogError) throw error
|
throwLiveCronValidationError(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}`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1310,6 +1331,7 @@ export function validateLiveScheduleChange(
|
|||||||
const record = foldScheduleEvents(events, seedLength).active.find(candidate => candidate.id === change.id)
|
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. */
|
/* v8 ignore next -- the preceding candidate fold requires calendar fields to target an active Cron record. */
|
||||||
if (record?.kind !== 'cron') return
|
if (record?.kind !== 'cron') return
|
||||||
|
validateLiveCronRule(record)
|
||||||
const expected = resolveCronOccurrence(record, Date.parse(change.acceptedAt))
|
const expected = resolveCronOccurrence(record, Date.parse(change.acceptedAt))
|
||||||
const nextScheduledAt = 'nextScheduledAt' in change ? change.nextScheduledAt : undefined
|
const nextScheduledAt = 'nextScheduledAt' in change ? change.nextScheduledAt : undefined
|
||||||
if (change.occurrenceAt !== expected.occurrenceAt || nextScheduledAt !== expected.nextScheduledAt) {
|
if (change.occurrenceAt !== expected.occurrenceAt || nextScheduledAt !== expected.nextScheduledAt) {
|
||||||
|
|||||||
@@ -143,6 +143,48 @@ describe('Schedule package invariant', () => {
|
|||||||
}, 0)],
|
}, 0)],
|
||||||
})
|
})
|
||||||
const fiber = await ctx.plugin(scheduleInvariant)
|
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 fiber.dispose()
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user