mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-18 18:33:06 +08:00
Preserve supported expression fields instead of stringifying them, and probe individual fields only when whole-expression validation fails. Convert unsupported syntax and weekday ranges ending in 7 while preserving legacy date/weekday rules and valid OR branches. Add execution regressions for issue #3099 and 64 calendar boundary cases in UTC and Asia/Shanghai. Ensure timezone child tests actually run under the Node test runner. Validation: 745 tests passed, 8 skipped; final targeted regressions passed; backend compilation passed. Differential calendar checks matched 142,360 execution timestamps. Fixes #3099
168 lines
5.7 KiB
TypeScript
168 lines
5.7 KiB
TypeScript
import CronExpressionParser from 'cron-parser';
|
|
import { validate } from 'node-cron';
|
|
import { ScheduleType } from '../interface/schedule';
|
|
|
|
const aliases: Record<string, string> = {
|
|
'@yearly': '0 0 0 1 1 *',
|
|
'@annually': '0 0 0 1 1 *',
|
|
'@monthly': '0 0 0 1 * *',
|
|
'@weekly': '0 0 0 * * 0',
|
|
'@daily': '0 0 0 * * *',
|
|
'@midnight': '0 0 0 * * *',
|
|
'@hourly': '0 0 * * * *',
|
|
'@minutely': '0 * * * * *',
|
|
};
|
|
|
|
export interface CronSchedule {
|
|
patterns: string[];
|
|
dayCount: number;
|
|
nthDay: number;
|
|
}
|
|
|
|
// Validation and registration repeatedly see identical rules in large snapshots.
|
|
// Cache only immutable syntax, never dates or running tasks, and bound user input.
|
|
const scheduleCache = new Map<string, CronSchedule>();
|
|
const daysInMonth = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
|
|
// Canonicalize legacy shorthand, aliases, names and numeric-start steps before
|
|
// node-cron sees them. Extend legacy aliases explicitly; reject H and bare /N.
|
|
export function parseCronSchedule(schedule: unknown): CronSchedule {
|
|
if (typeof schedule !== 'string' || !schedule.trim()) {
|
|
throw new Error('Invalid cron schedule');
|
|
}
|
|
let source = schedule.trim();
|
|
const cacheKey = source;
|
|
const cached = scheduleCache.get(cacheKey);
|
|
if (cached) return cached;
|
|
if (source.startsWith('@')) {
|
|
if (!Object.hasOwn(aliases, source)) throw new Error('Invalid cron alias');
|
|
source = aliases[source];
|
|
}
|
|
let parts = source.split(/\s+/);
|
|
if (
|
|
parts.length > 6 ||
|
|
parts.some((part) => /(^|,)\//.test(part) || /H(?:\(|\/|$)/i.test(part))
|
|
) {
|
|
throw new Error('Unsupported cron syntax');
|
|
}
|
|
parts = [
|
|
...['0', '*', '*', '*', '*', '*'].slice(0, 6 - parts.length),
|
|
...parts,
|
|
];
|
|
if (
|
|
parts.some(
|
|
(part, index) => index !== 3 && index !== 5 && part.includes('?'),
|
|
)
|
|
) {
|
|
throw new Error('Question mark is only valid in day fields');
|
|
}
|
|
const expression = CronExpressionParser.parse(parts.join(' '));
|
|
if (!expression.hasNext())
|
|
throw new Error('Cron schedule has no next execution');
|
|
let normalized = parts.map((part) => part.replace(/\?/g, '*'));
|
|
// cron-parser includes Sunday in stepped ranges ending in 7 even when the
|
|
// step does not land on 7. node-cron accepts those ranges with different days.
|
|
if (/(^|,)(?:\d+|[a-z]{3})-0*7\/\d+(?=,|$)/i.test(normalized[5])) {
|
|
normalized[5] = expression.fields.dayOfWeek.values.join(',');
|
|
}
|
|
// Most rules are already supported; only inspect individual fields on failure.
|
|
if (!validate(normalized.join(' '))) {
|
|
const fields = [
|
|
expression.fields.second,
|
|
expression.fields.minute,
|
|
expression.fields.hour,
|
|
expression.fields.dayOfMonth,
|
|
expression.fields.month,
|
|
expression.fields.dayOfWeek,
|
|
];
|
|
normalized = normalized.map((value, index) => {
|
|
// Preserve supported lists, ranges and names. Expand only fields node-cron
|
|
// cannot consume, such as numeric/name-start steps; never compress them.
|
|
const probe = ['0', '0', '0', '*', '*', '*'];
|
|
probe[index] = value;
|
|
if (validate(probe.join(' '))) return value;
|
|
const values = fields[index].values;
|
|
if (index === 5 && expression.fields.dayOfWeek.nthDay) {
|
|
// The legacy normalizer treated *#N / ?#N as an unrestricted weekday.
|
|
if (values.length === 8) return '*';
|
|
// # applies to a single weekday; normalize its numeric spelling only.
|
|
return values.length === 1
|
|
? `${values[0]}#${expression.fields.dayOfWeek.nthDay}`
|
|
: value;
|
|
}
|
|
return values.join(',');
|
|
});
|
|
}
|
|
const months = expression.fields.month.values;
|
|
// cron-parser 4 clipped dates outside a single selected month's maximum
|
|
// before deciding whether the day field covered the whole month.
|
|
const dayCount = expression.fields.dayOfMonth.values.filter(
|
|
(day) =>
|
|
months.length !== 1 ||
|
|
typeof day !== 'number' ||
|
|
day <= daysInMonth[months[0] - 1],
|
|
).length;
|
|
const weekCount = expression.fields.dayOfWeek.values.length;
|
|
// Preserve the legacy count-based wildcard rules, including repeated Sunday
|
|
// and L tokens: an ignored day field must not remain an AND constraint.
|
|
const weekdayPattern = [
|
|
...normalized.slice(0, 3),
|
|
'*',
|
|
...normalized.slice(4),
|
|
].join(' ');
|
|
const patterns =
|
|
weekCount === 8
|
|
? [[...normalized.slice(0, 5), '*'].join(' ')]
|
|
: dayCount < 31
|
|
? [[...normalized.slice(0, 5), '*'].join(' '), weekdayPattern]
|
|
: [weekdayPattern];
|
|
const valid = patterns.map((pattern) => validate(pattern));
|
|
if (patterns.length === 2 && !valid[0] && valid[1]) {
|
|
// A date branch can be impossible for all chosen months while the weekday
|
|
// branch still runs. Drop it only if its syntax is valid with any month.
|
|
if (validate([...normalized.slice(0, 4), '*', '*'].join(' '))) {
|
|
patterns.shift();
|
|
valid.shift();
|
|
}
|
|
}
|
|
if (!valid.every(Boolean)) {
|
|
throw new Error('Unsupported cron schedule');
|
|
}
|
|
const parsed = {
|
|
patterns,
|
|
dayCount,
|
|
nthDay: expression.fields.dayOfWeek.nthDay,
|
|
};
|
|
Object.freeze(patterns);
|
|
Object.freeze(parsed);
|
|
if (scheduleCache.size >= 512)
|
|
scheduleCache.delete(scheduleCache.keys().next().value);
|
|
scheduleCache.set(cacheKey, parsed);
|
|
return parsed;
|
|
}
|
|
|
|
export function isValidCronSchedule(schedule: unknown): schedule is string {
|
|
try {
|
|
parseCronSchedule(schedule);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function getInvalidCronSchedules(cron: {
|
|
schedule?: string;
|
|
extra_schedules?: Array<{ schedule: string }>;
|
|
}): unknown[] {
|
|
if (
|
|
cron.schedule?.startsWith(ScheduleType.ONCE) ||
|
|
cron.schedule?.startsWith(ScheduleType.BOOT)
|
|
) {
|
|
return [];
|
|
}
|
|
return [
|
|
cron.schedule,
|
|
...(cron.extra_schedules || []).map((x) => x.schedule),
|
|
].filter((schedule) => !isValidCronSchedule(schedule));
|
|
}
|