mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-01 05:13:50 +08:00
refactor: migrate cron scheduling to node-cron (#3079)
* refactor: migrate cron scheduling to node-cron * feat: support annually midnight and minutely cron macros * fix: harden cron recovery and system scheduler compatibility
This commit is contained in:
@@ -1,12 +1,93 @@
|
||||
import cronParser from 'cron-parser-v4';
|
||||
import CronExpressionParser from 'cron-parser';
|
||||
import { validate } from 'node-cron';
|
||||
import { ScheduleType } from '../interface/schedule';
|
||||
|
||||
// Keep validation aligned with node-schedule's locked cron-parser version.
|
||||
// cron-parser 5 accepts syntax (e.g. H) that the scheduler cannot execute.
|
||||
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>();
|
||||
|
||||
// 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');
|
||||
const normalized = expression.stringify(true).replace(/\?/g, '*').split(' ');
|
||||
const dayCount = expression.fields.dayOfMonth.values.length;
|
||||
const weekCount = expression.fields.dayOfWeek.values.length;
|
||||
const patterns =
|
||||
dayCount < 31 && weekCount < 8
|
||||
? [
|
||||
[...normalized.slice(0, 5), '*'].join(' '),
|
||||
[...normalized.slice(0, 3), '*', ...normalized.slice(4)].join(' '),
|
||||
]
|
||||
: [normalized.join(' ')];
|
||||
if (!patterns.every((pattern) => validate(pattern))) {
|
||||
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 {
|
||||
if (typeof schedule !== 'string' || !schedule.trim()) return false;
|
||||
try {
|
||||
return cronParser.parseExpression(schedule).hasNext();
|
||||
parseCronSchedule(schedule);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -22,6 +103,8 @@ export function getInvalidCronSchedules(cron: {
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [cron.schedule, ...(cron.extra_schedules || []).map((x) => x.schedule)]
|
||||
.filter((schedule) => !isValidCronSchedule(schedule));
|
||||
return [
|
||||
cron.schedule,
|
||||
...(cron.extra_schedules || []).map((x) => x.schedule),
|
||||
].filter((schedule) => !isValidCronSchedule(schedule));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createTask, ScheduledTask } from 'node-cron';
|
||||
import { parseCronSchedule } from './cronSchedule';
|
||||
|
||||
interface SchedulerLogger {
|
||||
warn(message: string, ...args: unknown[]): unknown;
|
||||
error(message: string, ...args: unknown[]): unknown;
|
||||
}
|
||||
|
||||
export interface CronJob {
|
||||
start(): void;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
// node-cron owns timers and calendar calculation; Qinglong owns execution
|
||||
// concurrency and process lifetime. Missed in-process slots join that same queue.
|
||||
export function createCronJob(
|
||||
schedule: string,
|
||||
callback: (date: Date) => unknown | Promise<unknown>,
|
||||
options: { name: string; logger: SchedulerLogger; start?: boolean },
|
||||
): CronJob {
|
||||
const parsed = parseCronSchedule(schedule);
|
||||
const tasks: ScheduledTask[] = [];
|
||||
let cancelled = false;
|
||||
let started = false;
|
||||
const accepts = (index: number, date: Date) => {
|
||||
if (tasks.length === 1) return true;
|
||||
// Preserve cron-parser 4's day/weekday OR semantics, including its
|
||||
// month-dependent full-day range and the global nth-week constraint.
|
||||
if (parsed.nthDay && Math.ceil(date.getDate() / 7) !== parsed.nthDay)
|
||||
return false;
|
||||
if (index === 1) return !tasks[0].match(date);
|
||||
const monthDays = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
return parsed.dayCount < monthDays[date.getMonth()] || tasks[1].match(date);
|
||||
};
|
||||
const dispatch = async (index: number, date: Date, missed: boolean) => {
|
||||
if (cancelled || !started || !accepts(index, date)) return;
|
||||
if (missed) {
|
||||
options.logger.warn(
|
||||
'[schedule][补执行迟到任务] 任务: %s, 计划时间: %s',
|
||||
options.name,
|
||||
date.toISOString(),
|
||||
);
|
||||
}
|
||||
try {
|
||||
await callback(date);
|
||||
} catch (error) {
|
||||
options.logger.error(
|
||||
'[schedule][定时回调失败] 任务: %s, 错误: %s',
|
||||
options.name,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
};
|
||||
try {
|
||||
for (const [index, pattern] of parsed.patterns.entries()) {
|
||||
const task = createTask(
|
||||
pattern,
|
||||
(context) => dispatch(index, context.date, false),
|
||||
{
|
||||
noOverlap: false,
|
||||
missedExecutionTolerance: 5000,
|
||||
},
|
||||
);
|
||||
tasks.push(task);
|
||||
task.on('execution:missed', (context) =>
|
||||
dispatch(index, context.date, true),
|
||||
);
|
||||
// Fail before an existing schedule is cancelled, including impossible dates.
|
||||
task.getNextRuns(1);
|
||||
}
|
||||
} catch (error) {
|
||||
tasks.forEach((task) => task.destroy());
|
||||
throw error;
|
||||
}
|
||||
const job: CronJob = {
|
||||
start() {
|
||||
if (started || cancelled) return;
|
||||
started = true;
|
||||
tasks.forEach((task) => task.start());
|
||||
},
|
||||
cancel() {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
tasks.forEach((task) => task.destroy());
|
||||
},
|
||||
};
|
||||
if (options.start !== false) job.start();
|
||||
return job;
|
||||
}
|
||||
Reference in New Issue
Block a user