mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-28 09:02:12 +08:00
fix: isolate invalid cron rules during scheduler recovery
This commit is contained in:
@@ -1,30 +1,15 @@
|
||||
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
|
||||
import { AddCronRequest, AddCronResponse } from '../protos/cron';
|
||||
import nodeSchedule from 'node-schedule';
|
||||
import CronExpressionParser from 'cron-parser';
|
||||
import { isValidCronSchedule } from '../shared/cronSchedule';
|
||||
import { scheduleStacks } from './data';
|
||||
import { runCron } from '../shared/runCron';
|
||||
import Logger from '../loaders/logger';
|
||||
import { tf } from '../shared/i18n';
|
||||
|
||||
/**
|
||||
* 预校验 cron 表达式,检测 node-schedule 会拒绝但 cron-parser 会接受的 pattern。
|
||||
* node-schedule 对 bare /N(字段以 / 开头,如前无星号/数字前缀的 /6)返回 null,
|
||||
* 提前拦截避免走 scheduleJob 后才发现无效。
|
||||
*/
|
||||
// Validate the entire batch before replacing any existing jobs.
|
||||
const isValidCronField = (cron: string): boolean => {
|
||||
// 检测 bare /N 模式:字段以 / 开头如 "/6",或空格后紧跟 "/6"
|
||||
// node-schedule 会对这种字段返回 null
|
||||
if (/\s\/\d/.test(cron) || /^\/\d/.test(cron)) {
|
||||
return false;
|
||||
}
|
||||
// 日期和星期字段的 ? 是合法通配符。解析完整表达式,避免将单独的
|
||||
// ? 等无效规则放行后,在替换恢复快照时清除已有任务。
|
||||
try {
|
||||
return CronExpressionParser.parse(cron).hasNext();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return isValidCronSchedule(cron);
|
||||
};
|
||||
|
||||
const addCron = (
|
||||
|
||||
+22
-4
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { getInvalidCronSchedules } from '../shared/cronSchedule';
|
||||
import {
|
||||
withSchedulerMutation,
|
||||
schedulerRegistrationError,
|
||||
@@ -46,10 +47,13 @@ export default class CronService {
|
||||
|
||||
private isNodeCron(cron: Crontab) {
|
||||
const { schedule, extra_schedules } = cron;
|
||||
if (Number(schedule?.split(/ +/).length) > 5 || extra_schedules?.length) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
// System crontab only receives portable numeric five-field expressions.
|
||||
// Extended syntax, macros and legacy shorthand belong to node-schedule.
|
||||
return (
|
||||
schedule?.trim().split(/\s+/).length !== 5 ||
|
||||
/[^\d\s*,/\-]/.test(schedule || '') ||
|
||||
Boolean(extra_schedules?.length)
|
||||
);
|
||||
}
|
||||
|
||||
private get schedulerMode(): 'system' | 'node' {
|
||||
@@ -1075,6 +1079,20 @@ export default class CronService {
|
||||
public async autosave_crontab(requireScheduler = false) {
|
||||
return withSchedulerMutation(async () => {
|
||||
const tabs = await this.crontabs();
|
||||
// A bad persisted rule must not block startup or all other schedules.
|
||||
// Keep the DB row editable; omit it from both runtime scheduler snapshots.
|
||||
tabs.data = tabs.data.filter((doc) => {
|
||||
if (doc.isDisabled === 1) return true;
|
||||
const invalidSchedules = getInvalidCronSchedules(doc);
|
||||
if (invalidSchedules.length === 0) return true;
|
||||
this.logger.warn(
|
||||
'[crontab] Skipping task with invalid schedule (id=%s, name=%s): %s',
|
||||
doc.id,
|
||||
doc.name || '',
|
||||
JSON.stringify(invalidSchedules),
|
||||
);
|
||||
return false;
|
||||
});
|
||||
const regularCrons = tabs.data
|
||||
.filter(
|
||||
(x) =>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import cronParser from 'cron-parser-v4';
|
||||
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.
|
||||
export function isValidCronSchedule(schedule: unknown): schedule is string {
|
||||
if (typeof schedule !== 'string' || !schedule.trim()) return false;
|
||||
try {
|
||||
return cronParser.parseExpression(schedule).hasNext();
|
||||
} 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));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Joi } from 'celebrate';
|
||||
import CronExpressionParser from 'cron-parser';
|
||||
import { isValidCronSchedule } from '../shared/cronSchedule';
|
||||
import { ScheduleType } from '../interface/schedule';
|
||||
import path from 'path';
|
||||
import config from '../config';
|
||||
@@ -12,18 +12,7 @@ const validateSchedule = (value: string, helpers: any) => {
|
||||
return value;
|
||||
}
|
||||
|
||||
// 检测裸 /N 模式:cron-parser 会接受,但 node-schedule 会返回 null
|
||||
// 提前拦截,避免任务入库后调度器注册失败
|
||||
if (/\s\/\d/.test(value) || /^\/\d/.test(value)) {
|
||||
return helpers.error('any.invalid');
|
||||
}
|
||||
try {
|
||||
if (CronExpressionParser.parse(value).hasNext()) {
|
||||
return value;
|
||||
}
|
||||
} catch (e) {
|
||||
return helpers.error('any.invalid');
|
||||
}
|
||||
if (isValidCronSchedule(value)) return value;
|
||||
return helpers.error('any.invalid');
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
- 修复历史任务中合法的 `?` 定时表达式被拒绝,导致启动恢复失败、面板健康检查返回 503 的问题(#3078)。主规则和附加规则均保留兼容性,无效规则仍在替换现有任务前拒绝。
|
||||
- 修复 `ql check` 等 Shell 诊断输出以 `--` 开头时的 `printf: --: invalid option`,补全调度注册失败日志中的错误详情。
|
||||
- 已使用真实调度任务验证恢复流程;本地回归 237 项,234 通过、3 项平台相关跳过,后端构建通过。
|
||||
- 启动恢复跳过无效主规则或附加规则的任务,记录任务 ID、名称和错误规则;数据库原记录保留可编辑,其他任务与面板正常启动。服务连接故障仍返回 503。
|
||||
- 校验使用与实际调度器一致的解析器,避免接受 `H` 等无法执行的语法;五字段扩展规则、宏和历史简写交给 Node 调度器,避免系统 crontab 拒绝整个任务快照。
|
||||
- 已对 250 多个表达式样本与真实调度库做一致性校验,验证无效任务隔离和两种调度模式;本地回归 240 项,237 通过、3 项平台相关跳过,后端构建通过。
|
||||
|
||||
## 新增功能
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"cron-parser": "^5.4.0",
|
||||
"cron-parser-v4": "npm:cron-parser@4.9.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.6",
|
||||
|
||||
Generated
+3
@@ -42,6 +42,9 @@ dependencies:
|
||||
cron-parser:
|
||||
specifier: ^5.4.0
|
||||
version: 5.4.0
|
||||
cron-parser-v4:
|
||||
specifier: npm:cron-parser@4.9.0
|
||||
version: /cron-parser@4.9.0
|
||||
cross-spawn:
|
||||
specifier: ^7.0.6
|
||||
version: 7.0.6
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const nodeSchedule = require('node-schedule');
|
||||
const { isValidCronSchedule } = require('../../back/shared/cronSchedule');
|
||||
|
||||
test('validation agrees with the actual scheduler across legacy cron syntax', () => {
|
||||
const candidates = new Set([
|
||||
'*', '0', '?', '0 0', '0 0 *', '0 0 * *',
|
||||
'@yearly', '@annually', '@monthly', '@weekly', '@daily', '@midnight',
|
||||
'@hourly', '@secondly', '@minutely', '@weekdays', '@weekends', '@reboot',
|
||||
'0 0 1 1 * 2027', '0 0 0 L * *', '0 0 0 * * 5L',
|
||||
'0 0 0 * * MON#2', '0 0 12 LW * *', '0 0 12 15W * *',
|
||||
'0 0 0 ? * MON', '0 0/30 * * * ?', '0 /5 * * * *',
|
||||
]);
|
||||
const fields = [
|
||||
['*', '?', '/5', '*/5', '0/5', '0', '01', '1-5', '1,3', '5-1', 'H', 'H/5', 'H(0-10)', 'L'],
|
||||
['*', '?', '/5', '*/5', '0/5', '0', '01', '1-5', '1,3', '5-1', 'H', 'H/5', 'H(0-10)', 'L'],
|
||||
['*', '?', '/2', '*/2', '0/2', '0', '01', '1-5', '1,3', '23-2', 'H', 'L'],
|
||||
['*', '?', '/2', '*/2', '1/2', '1', '01', '1-5', '1,3', 'L', 'L-1', 'LW', '15W', 'H'],
|
||||
['*', '?', '/2', '*/2', '1/2', '1', '01', '1-5', '1,3', 'JAN', 'jan', 'JAN-MAR', 'DEC-FEB', 'H'],
|
||||
['*', '?', '/2', '*/2', '0/2', '0', '7', '01', '1-5', '1,3', 'MON', 'mon', 'MON-FRI', 'FRI-MON', '5L', 'L', 'MON#2', '1#5', 'H'],
|
||||
];
|
||||
fields.forEach((options, index) => options.forEach((field) => {
|
||||
const values = ['0', '0', '0', '*', '*', '*'];
|
||||
values[index] = field;
|
||||
candidates.add(values.join(' '));
|
||||
}));
|
||||
for (const value of [...candidates]) {
|
||||
if (value.split(' ').length === 6) {
|
||||
candidates.add(` ${value} `);
|
||||
candidates.add(value.split(' ').slice(1).join(' '));
|
||||
}
|
||||
}
|
||||
for (const value of candidates) {
|
||||
const job = nodeSchedule.scheduleJob(value, () => {});
|
||||
const accepted = Boolean(job);
|
||||
job?.cancel();
|
||||
assert.equal(isValidCronSchedule(value), accepted, value);
|
||||
}
|
||||
assert.ok(candidates.size > 250);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const ts = require('typescript');
|
||||
const { ScheduleType } = require('../../back/interface/schedule');
|
||||
|
||||
test('system and node modes route extended cron once and keep portable rules in crontab', async () => {
|
||||
const source = fs.readFileSync('back/services/cron.ts', 'utf8');
|
||||
const begin = source.indexOf(' private isNodeCron(');
|
||||
const end = source.indexOf(' private async getLogName', begin);
|
||||
const saveBegin = source.indexOf(' private async setCrontab(');
|
||||
const saveEnd = source.indexOf(' public importCrontab(', saveBegin);
|
||||
const js = ts.transpileModule(`class Fixture {${source.slice(begin, end)}${source.slice(saveBegin, saveEnd)}}; module.exports = Fixture;`, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
for (const mode of ['system', 'node']) {
|
||||
const module = { exports: {} };
|
||||
let file = '', installs = 0;
|
||||
new Function('module', 'process', 'execSync', 'ScheduleType', 'config', 'writeFileWithLock', 'CrontabModel', js)(
|
||||
module, { env: { QL_SCHEDULER: mode } }, () => { installs++; }, ScheduleType,
|
||||
{ crontabFile: '/unused' }, async (_, data) => { file = data; }, { update: async () => {} },
|
||||
);
|
||||
const service = new module.exports();
|
||||
service.makeCommand = (row) => `task ${row.id}.js`;
|
||||
const rows = [
|
||||
{ id: 'plain', schedule: '*/5 0-23 * * 1,3' },
|
||||
{ id: 'spaces', schedule: ' 0\t0 * * * ' },
|
||||
{ id: 'seconds', schedule: '0 0 * * * *' },
|
||||
{ id: 'question', schedule: '0 0 * * ?' },
|
||||
{ id: 'last', schedule: '0 0 L * *' },
|
||||
{ id: 'nth', schedule: '0 0 * * 1#2' },
|
||||
{ id: 'named', schedule: '0 0 * JAN MON' },
|
||||
{ id: 'macro', schedule: '@daily' },
|
||||
{ id: 'short', schedule: '*' },
|
||||
{ id: 'extra', schedule: '* * * * *', extra_schedules: [{ schedule: '0 * * * *' }] },
|
||||
{ id: 'once', schedule: '@once' },
|
||||
{ id: 'boot', schedule: '@boot' },
|
||||
];
|
||||
for (const row of rows) {
|
||||
const special = ['once', 'boot'].includes(row.id);
|
||||
const portable = ['plain', 'spaces'].includes(row.id);
|
||||
assert.equal(service.shouldUseCronClient(row), !special && (mode === 'node' || !portable), `${mode}: ${row.id}`);
|
||||
}
|
||||
await service.setCrontab({ data: rows, total: rows.length });
|
||||
assert.equal(installs, mode === 'system' ? 1 : 0);
|
||||
for (const id of ['seconds', 'question', 'last', 'nth', 'named', 'macro', 'short', 'extra', 'once', 'boot']) {
|
||||
const line = file.split('\n').find((line) => line.endsWith(`task ${id}.js`));
|
||||
assert.ok(line.startsWith('# '), `${mode}: ${id} cannot also run from system crontab`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -22,7 +22,7 @@ test('API accepts legacy question-mark schedules but rejects malformed cron', ()
|
||||
for (const schedule of [...legacySchedules, '@once', '@boot']) {
|
||||
assert.equal(scheduleSchema.validate(schedule).error, undefined, schedule);
|
||||
}
|
||||
for (const schedule of ['?', '0 /5 * * * ?', '0 70 * * * ?', 'not a cron']) {
|
||||
for (const schedule of ['@bogus', '0 /5 * * * ?', '0 70 * * * ?', 'not a cron']) {
|
||||
assert.ok(scheduleSchema.validate(schedule).error, schedule);
|
||||
}
|
||||
});
|
||||
@@ -57,7 +57,7 @@ test('legacy main and extra schedules restore real jobs and healthy readiness',
|
||||
assert.ok(jobs.every((job) => job.nextInvocation()));
|
||||
}
|
||||
const previousJobs = [...stacks.values()].flat();
|
||||
for (const schedule of ['?', '0 /5 * * * ?', '0 70 * * * ?']) {
|
||||
for (const schedule of ['@bogus', '0 /5 * * * ?', '0 70 * * * ?']) {
|
||||
for (const invalid of [
|
||||
{ ...crons[0], schedule },
|
||||
{ ...crons[0], extra_schedules: [{ schedule }] },
|
||||
|
||||
@@ -5,6 +5,7 @@ const ts = require('typescript');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { SchedulerReadiness } = require('../../back/shared/schedulerReadiness');
|
||||
const { AddCronRequest } = require('../../back/protos/cron');
|
||||
const { getInvalidCronSchedules } = require('../../back/shared/cronSchedule');
|
||||
|
||||
function fixture(client) {
|
||||
const source = fs.readFileSync('back/services/cron.ts', 'utf8');
|
||||
@@ -15,11 +16,12 @@ function fixture(client) {
|
||||
{ compilerOptions: { target: ts.ScriptTarget.ES2020 } },
|
||||
).outputText;
|
||||
const module = { exports: {} };
|
||||
new Function('module', 'isDemoEnv', 'cronClient', 'withSchedulerMutation', js)(
|
||||
new Function('module', 'isDemoEnv', 'cronClient', 'withSchedulerMutation', 'getInvalidCronSchedules', js)(
|
||||
module,
|
||||
() => false,
|
||||
client,
|
||||
(fn) => fn(),
|
||||
getInvalidCronSchedules,
|
||||
);
|
||||
const service = new module.exports();
|
||||
service.setCrontab = async () => {};
|
||||
@@ -101,7 +103,7 @@ test('invalid replacement leaves the previous schedule intact', async () => {
|
||||
{
|
||||
request: {
|
||||
replace: true,
|
||||
crons: [{ id: 'bad', schedule: '?', extra_schedules: [] }],
|
||||
crons: [{ id: 'bad', schedule: 'not a cron', extra_schedules: [] }],
|
||||
},
|
||||
},
|
||||
(err) => (err ? reject(err) : resolve()),
|
||||
@@ -111,6 +113,56 @@ test('invalid replacement leaves the previous schedule intact', async () => {
|
||||
assert.deepEqual([...stacks.keys()], ['old']);
|
||||
});
|
||||
|
||||
test('invalid persisted schedules cannot block recovery, valid jobs or HTTP health', async (t) => {
|
||||
const stacks = new Map();
|
||||
const warnings = [];
|
||||
const { addCron } = load('back/schedule/addCron.ts', {
|
||||
'./data': { scheduleStacks: stacks },
|
||||
'../shared/runCron': {},
|
||||
'../loaders/logger': { info() {}, warn() {} },
|
||||
'../shared/i18n': { tf: require('node:util').format },
|
||||
});
|
||||
const client = { addCron: (crons, replace) => new Promise((resolve, reject) => {
|
||||
addCron({ request: { crons, replace } }, (error) => error ? reject(error) : resolve());
|
||||
}) };
|
||||
const service = fixture(client);
|
||||
let snapshot;
|
||||
service.setCrontab = async (tabs) => { snapshot = tabs.data; };
|
||||
service.logger.warn = (...args) => warnings.push(require('node:util').format(...args));
|
||||
let rows = [
|
||||
{ id: 'good', name: 'valid', schedule: '0 0/30 * * * ?', isDisabled: 0 },
|
||||
{ id: 'bad-main', name: 'bad main', schedule: 'not a cron', isDisabled: 0 },
|
||||
{ id: 'bad-extra', name: 'bad extra', schedule: '* * * * *', extra_schedules: [{ schedule: '0 70 * * * ?' }], isDisabled: 0 },
|
||||
{ id: 'unsupported', name: 'unsupported', schedule: 'H * * * *', isDisabled: 0 },
|
||||
{ id: 'disabled', name: 'disabled', schedule: 'bad', isDisabled: 1 },
|
||||
];
|
||||
service.crontabs = async () => ({ data: rows });
|
||||
const state = new SchedulerReadiness(async () => {}, 60000);
|
||||
t.after(() => {
|
||||
clearTimeout(state.retry);
|
||||
for (const jobs of stacks.values()) for (const job of jobs) job.cancel();
|
||||
});
|
||||
state.configure(() => service.autosave_crontab(true));
|
||||
assert.equal(await state.recover(), true);
|
||||
assert.deepEqual([...stacks.keys()], ['good']);
|
||||
assert.deepEqual(snapshot.map((x) => x.id), ['good', 'disabled']);
|
||||
assert.equal(rows.length, 5, 'persisted rows are preserved for editing');
|
||||
assert.equal(rows[1].isDisabled, 0);
|
||||
assert.equal(warnings.length, 3);
|
||||
assert.match(warnings.join('\n'), /bad-main.*bad main.*not a cron/);
|
||||
assert.match(warnings.join('\n'), /bad-extra.*0 70/);
|
||||
const { HealthService } = load('back/services/health.ts', {
|
||||
typedi: { Service: () => (x) => x }, './http': {},
|
||||
'../schedule/client': { readiness: state }, '../loaders/logger': { error() {} },
|
||||
});
|
||||
const health = new HealthService({ getServer: () => ({}) });
|
||||
assert.equal((await health.check()).status, 'ok');
|
||||
rows = rows.filter((x) => x.id !== 'good');
|
||||
assert.equal(await state.recover(), true, 'even all-invalid snapshots can recover');
|
||||
assert.equal(stacks.size, 0);
|
||||
assert.equal((await health.check()).status, 'ok');
|
||||
});
|
||||
|
||||
test('pre-RPC channel failures invalidate readiness and return 503 without executing or replaying writes', async () => {
|
||||
for (const method of ['addCron', 'delCron']) {
|
||||
let invalidations = 0,
|
||||
|
||||
+2
-1
@@ -1,8 +1,9 @@
|
||||
version: 2.22.0
|
||||
changeLogLink: https://github.com/whyour/qinglong/blob/master/docs/releases/2.22.0.md
|
||||
publishTime: 2026-09-27 1615
|
||||
publishTime: 2026-09-27 1643
|
||||
changeLog: |
|
||||
修复更新后含 ? 的历史定时规则导致面板 503,以及 ql check 的 printf 报错(#3078)
|
||||
无效任务不再阻止面板启动;统一调度表达式校验并修复扩展规则的调度器分配
|
||||
1. 新增独立远程 CLI,覆盖任务、订阅、环境变量、配置、脚本等 OpenAPI
|
||||
2. 新增可选面板内部 TypeScript 工具,默认保留 Shell 入口
|
||||
3. 仪表盘支持查看今日成功和失败任务明细
|
||||
|
||||
Reference in New Issue
Block a user