mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-18 18:15:45 +08:00
fix: preserve cron semantics during node-cron adaptation
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
This commit is contained in:
@@ -22,6 +22,7 @@ export interface CronSchedule {
|
||||
// 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.
|
||||
@@ -58,17 +59,73 @@ export function parseCronSchedule(schedule: unknown): CronSchedule {
|
||||
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;
|
||||
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 =
|
||||
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))) {
|
||||
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 = {
|
||||
|
||||
@@ -10,6 +10,10 @@ Node 调度路径使用固定版本 `node-cron@4.6.0`,普通任务(含额外
|
||||
- 日期与星期同时指定时保留 OR 语义,不采用 node-cron 默认的 AND。两个原生任务分别处理日期和星期,并按计划时间过滤交集,避免重复执行;保留旧解析器的月份全日期范围及全局 nth-week 限制。
|
||||
- 原生任务先暂停创建并验证,再替换旧任务。构造失败时销毁已准备的实例并保留旧快照。
|
||||
- 进程内迟到的触发通过 `execution:missed` 加入既有执行队列,记录补执行日志。并发和单任务积压上限仍由青龙队列控制;这不提供进程退出期间的持久化补跑或分布式 exactly-once 保证。
|
||||
- 保留 node-cron 已支持的列表、范围和名称,不调用 cron-parser 的 `stringify()` 重写整条表达式。先校验整条表达式,通过则跳过逐字段探测;失败时才检查并展开不支持的字段(如数字/名称起点步长)。保留原有 `?` 和 `#` 兼容处理,避免有效的 `6,12,18` 被压缩为不兼容的 `6/6`;日期/星期拆分后的最终校验仍然保留。
|
||||
- 星期步长范围以 `7` 结尾时,先按 cron-parser 的解析值展开。例如 `2-7/4` 在现有解析器中包含星期日,而 node-cron 原样执行会遗漏星期日;仅通过语法校验不足以保证执行含义一致。
|
||||
- 单月规则先剔除超出该月最大日期的值再计算日期数量,保留旧版日期/星期的通配计数语义。被判为通配的字段不再作为另一侧的 AND 约束,避免 `2-31 4 MON` 和包含 `L`、重复星期日的边界规则漏执行。
|
||||
- 日期与星期按 OR 拆分后,若日期分支在所有指定月份中都不可达,而星期分支有效,则仅保留星期分支;移除前仍检查日期分支在任意月份下语法有效。`31 2,4 MON` 因而可以执行,单独的 `31 2,4 *` 仍拒绝。
|
||||
- 标准化结果使用上限 512 条的只读缓存,仅缓存语法,不缓存运行实例或下次执行时间。避免大批相同表达式重复解析导致恢复 RPC 超时。
|
||||
- 回调异常记录日志,后续调度继续;取消时调用原生 `destroy()` 释放计时器和注册表。
|
||||
- 持久化无效任务在恢复时跳过并记录日志,无效订阅不会产生未处理的启动拒绝。API、导入和执行使用同一套表达式校验。
|
||||
@@ -31,6 +35,7 @@ Node 调度路径使用固定版本 `node-cron@4.6.0`,普通任务(含额外
|
||||
|
||||
- `test/fixtures/legacy-cron.json` 是迁移前使用 node-schedule 2.1.1 / cron-parser 4.9.0 保存的快照:269 条语法判定,18 组日历序列,每组 10 次执行。
|
||||
- 真正的 node-cron 实例配合虚拟时钟验证日期/星期 OR、交集去重、闰年、L/#、8 秒迟到补执行、取消释放、回调拒绝和夏令时。
|
||||
- `test/fixtures/cron-calendar-boundaries.json` 保存 64 组规则与起点,以独立 cron-parser 4.9.0 / luxon 3.7.2 或公历计算产生预期结果,每组在 UTC、Asia/Shanghai 下各核对 4 次实际执行,包括 2100 年不闰和长周期第五个星期日的边界。时区子测试清理继承的 `NODE_TEST_CONTEXT` 并检查子测试输出,防止嵌套测试入口被跳过而出现假通过。
|
||||
- 注册构造失败回滚、无效订阅隔离、空表达式立即运行,以及现有调度恢复/503/子进程测试覆盖接入流程。
|
||||
- 隔离的正式 Debian 镜像使用实际编译产物验证 10,000 条有效任务和 2 条错误任务的恢复、300 个同时到期的 Python 脚本、并发上限 16 及 HTTP 健康检查。具体结果见本次测试报告;这是生产镜像隔离验证,不是线上长期运行证明。
|
||||
|
||||
@@ -47,3 +52,11 @@ Node 调度路径使用固定版本 `node-cron@4.6.0`,普通任务(含额外
|
||||
完整回归 267 通过、3 项原有跳过;Node 20 的 10 项修复回归及 Alpine 镜像的 24 项兼容/宏测试通过。
|
||||
|
||||
额外使用 9,700 个不同表达式和 300 个同时到期任务验证恢复(另有 2 个无效任务),不再局限于共享两个表达式。300/300 脚本完成,无重复或遗漏,最大并发 16,270 个退出码 0、30 个预设退出码 3 均落库。恢复后健康检查 1,650/1,650 成功、任务列表 33/33 成功;启动恢复阶段另有 260 次健康检查失败,整批脚本耗时 60.27 秒。较大快照恢复仍需等待,不承诺启动阶段始终可用或任意规模都能在时限内恢复。
|
||||
|
||||
### 深度兼容性审计(2026-10-17)
|
||||
|
||||
保持 cron-parser 5.4.0 和 node-cron 4.6.0,扫描 19,811 个表达式,覆盖六个字段的数字、前导零、列表、范围、起点步长、名称、L/#/? 及日期/星期组合。发现并修复星期步长末尾 7、单月日期裁剪、通配计数边界和不可达日期 OR 分支问题;47 个初始失败样例已保存为回归数据。
|
||||
|
||||
UTC 和 Asia/Shanghai 各比较 17,795 条规则的连续 4 次实际触发,共核对 142,360 个触发时刻,修复后无偏差、无构造失败。每个时区另外有 71 个通配 `#` 组合沿用已明确保留的旧适配规则,不与原解析器直接对照;22 个原版拒绝的日期输入仍拒绝,11 个参考迭代器触及搜索上限的长周期规则改用独立公历预期验证。
|
||||
|
||||
持久化回归覆盖 64 组规则与起点、两个时区,共 128 组日历用例;还保留 H、裸 `/N`、非标准 L 写法及不可达规则的拒绝行为。完整回归 745 通过、8 跳过,后端编译通过。本次为确定性语法与日历测试,不涉及生产环境压力或长期稳定性验证。
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { createCronJob } = require('../../back/shared/cronScheduler');
|
||||
const { isValidCronSchedule } = require('../../back/shared/cronSchedule');
|
||||
const native = require('node-cron');
|
||||
const fixture = require('../fixtures/cron-calendar-boundaries.json');
|
||||
|
||||
// Each deployment gets a fresh timezone formatter cache in node-cron.
|
||||
const timezone = process.env.QL_CRON_BOUNDARY_TEST_TZ;
|
||||
if (!timezone) {
|
||||
for (const tz of ['UTC', 'Asia/Shanghai']) {
|
||||
test(`cron calendar boundary regressions in ${tz}`, () => {
|
||||
const env = { ...process.env, TZ: tz, QL_CRON_BOUNDARY_TEST_TZ: tz };
|
||||
// A nested --test runner otherwise exits successfully without running tests.
|
||||
delete env.NODE_TEST_CONTEXT;
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['-r', 'ts-node/register/transpile-only', '--test', __filename],
|
||||
{
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
assert.match(result.stdout, /calendar boundary:/);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const { schedule, currentDate, next } of fixture.cases) {
|
||||
test(`calendar boundary: ${schedule} from ${currentDate}`, async (t) => {
|
||||
t.mock.timers.enable({
|
||||
apis: ['Date', 'setTimeout', 'setInterval'],
|
||||
now: new Date(currentDate),
|
||||
});
|
||||
const before = native.getTasks().size;
|
||||
const actual = [];
|
||||
const job = createCronJob(
|
||||
schedule,
|
||||
(date) => actual.push(date.toISOString()),
|
||||
{ name: 'calendar-boundary', logger: { warn() {}, error() {} } },
|
||||
);
|
||||
t.after(() => job.cancel());
|
||||
|
||||
for (const [index, expected] of next[timezone].entries()) {
|
||||
t.mock.timers.tick(new Date(expected).getTime() - Date.now() - 1);
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(actual, next[timezone].slice(0, index));
|
||||
t.mock.timers.tick(1);
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(actual, next[timezone].slice(0, index + 1));
|
||||
}
|
||||
job.cancel();
|
||||
assert.equal(native.getTasks().size, before);
|
||||
});
|
||||
}
|
||||
|
||||
test('unreachable dates and unsupported L spellings remain invalid', () => {
|
||||
for (const schedule of [
|
||||
'0 0 31 2,4 *',
|
||||
'0 0 31 2,4 0-7',
|
||||
'0 0 1L 2,4 MON',
|
||||
'0 0 * * 05L',
|
||||
]) {
|
||||
assert.equal(isValidCronSchedule(schedule), false, schedule);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -7,10 +7,13 @@ const { isValidCronSchedule } = require('../../back/shared/cronSchedule');
|
||||
if (!process.env.QL_MACRO_TEST_TZ) {
|
||||
for (const tz of ['UTC', 'Asia/Shanghai']) {
|
||||
test(`macro execution in ${tz}`, () => {
|
||||
const env = { ...process.env, TZ: tz, QL_MACRO_TEST_TZ: tz };
|
||||
delete env.NODE_TEST_CONTEXT;
|
||||
const result = require('node:child_process').spawnSync(process.execPath,
|
||||
['-r', 'ts-node/register/transpile-only', '--test', __filename],
|
||||
{ env: { ...process.env, TZ: tz, QL_MACRO_TEST_TZ: tz }, encoding: 'utf8' });
|
||||
{ env, encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
assert.match(result.stdout, /@annually fires at the expected local times/);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { validate } = require('node-cron');
|
||||
const {
|
||||
parseCronSchedule,
|
||||
isValidCronSchedule,
|
||||
getInvalidCronSchedules,
|
||||
} = require('../../back/shared/cronSchedule');
|
||||
const { createCronJob } = require('../../back/shared/cronScheduler');
|
||||
|
||||
const logger = { warn() {}, error() {} };
|
||||
|
||||
// Issue #3099: cron-parser 5.4 stringifies these hours as 6/6, which
|
||||
// node-cron rejects. Preserve supported fields and observe actual executions.
|
||||
const cases = [
|
||||
...[
|
||||
'0 6,12,18 * * *',
|
||||
'0 0 6,12,18 * * *',
|
||||
'0 6-18/6 * * *',
|
||||
'0 6/6 * * *',
|
||||
].map((schedule) => ({
|
||||
schedule,
|
||||
next: [
|
||||
'2026-10-17T06:00:00.000Z',
|
||||
'2026-10-17T12:00:00.000Z',
|
||||
'2026-10-17T18:00:00.000Z',
|
||||
'2026-10-18T06:00:00.000Z',
|
||||
],
|
||||
})),
|
||||
{
|
||||
schedule: '5,15,25 * * * *',
|
||||
next: [
|
||||
'2026-10-17T00:05:00.000Z',
|
||||
'2026-10-17T00:15:00.000Z',
|
||||
'2026-10-17T00:25:00.000Z',
|
||||
'2026-10-17T01:05:00.000Z',
|
||||
],
|
||||
},
|
||||
{
|
||||
schedule: '5,15,25 * * * * *',
|
||||
next: [
|
||||
'2026-10-17T00:00:05.000Z',
|
||||
'2026-10-17T00:00:15.000Z',
|
||||
'2026-10-17T00:00:25.000Z',
|
||||
'2026-10-17T00:01:05.000Z',
|
||||
],
|
||||
},
|
||||
{
|
||||
schedule: '5/20 * * * * *',
|
||||
next: [
|
||||
'2026-10-17T00:00:05.000Z',
|
||||
'2026-10-17T00:00:25.000Z',
|
||||
'2026-10-17T00:00:45.000Z',
|
||||
'2026-10-17T00:01:05.000Z',
|
||||
],
|
||||
},
|
||||
{
|
||||
schedule: '5/20 * * * *',
|
||||
next: [
|
||||
'2026-10-17T00:05:00.000Z',
|
||||
'2026-10-17T00:25:00.000Z',
|
||||
'2026-10-17T00:45:00.000Z',
|
||||
'2026-10-17T01:05:00.000Z',
|
||||
],
|
||||
},
|
||||
{
|
||||
schedule: '0 0 1/10,L * MON',
|
||||
next: [
|
||||
'2026-10-19T00:00:00.000Z',
|
||||
'2026-10-21T00:00:00.000Z',
|
||||
'2026-10-26T00:00:00.000Z',
|
||||
'2026-10-31T00:00:00.000Z',
|
||||
'2026-11-01T00:00:00.000Z',
|
||||
],
|
||||
},
|
||||
{
|
||||
schedule: '0 0 1 JAN/3 *',
|
||||
next: [
|
||||
'2027-01-01T00:00:00.000Z',
|
||||
'2027-04-01T00:00:00.000Z',
|
||||
'2027-07-01T00:00:00.000Z',
|
||||
'2027-10-01T00:00:00.000Z',
|
||||
],
|
||||
},
|
||||
{
|
||||
schedule: '0 0 * * MON/2',
|
||||
next: [
|
||||
'2026-10-18T00:00:00.000Z',
|
||||
'2026-10-19T00:00:00.000Z',
|
||||
'2026-10-21T00:00:00.000Z',
|
||||
'2026-10-23T00:00:00.000Z',
|
||||
'2026-10-25T00:00:00.000Z',
|
||||
],
|
||||
},
|
||||
{
|
||||
schedule: '0 0 * * 00#02',
|
||||
next: [
|
||||
'2026-11-08T00:00:00.000Z',
|
||||
'2026-12-13T00:00:00.000Z',
|
||||
'2027-01-10T00:00:00.000Z',
|
||||
'2027-02-14T00:00:00.000Z',
|
||||
],
|
||||
},
|
||||
...['*#2', '?#2'].map((weekday) => ({
|
||||
schedule: `0 0 * * ${weekday}`,
|
||||
next: [
|
||||
'2026-10-18T00:00:00.000Z',
|
||||
'2026-10-19T00:00:00.000Z',
|
||||
'2026-10-20T00:00:00.000Z',
|
||||
'2026-10-21T00:00:00.000Z',
|
||||
],
|
||||
})),
|
||||
];
|
||||
|
||||
test('supported fields retain their lists, ranges, names and special days', () => {
|
||||
for (const [schedule, patterns] of [
|
||||
['0 6,12,18 * * *', ['0 0 6,12,18 * * *']],
|
||||
['5,15,25 * * JAN-MAR MON-FRI', ['0 5,15,25 * * JAN-MAR MON-FRI']],
|
||||
['0 0 L JAN *', ['0 0 0 L JAN *']],
|
||||
['0 0 * JAN MON#2', ['0 0 0 * JAN MON#2']],
|
||||
['0 0 1,15 JAN MON', ['0 0 0 1,15 JAN *', '0 0 0 * JAN MON']],
|
||||
['0 0 ? * MON', ['0 0 0 * * MON']],
|
||||
]) {
|
||||
assert.deepEqual(parseCronSchedule(schedule).patterns, patterns, schedule);
|
||||
}
|
||||
});
|
||||
|
||||
for (const [schedule, patterns, maxValidations] of [
|
||||
['0 6,12,18 * * *', ['0 0 6,12,18 * * *'], 2],
|
||||
['0 0 1,15 JAN MON', ['0 0 0 1,15 JAN *', '0 0 0 * JAN MON'], 3],
|
||||
]) {
|
||||
test(`supported expressions skip field validation: ${schedule}`, () => {
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const validations = [];
|
||||
const isolated = load('back/shared/cronSchedule.ts', {
|
||||
'node-cron': {
|
||||
validate(pattern) {
|
||||
validations.push(pattern);
|
||||
return validate(pattern);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(isolated.parseCronSchedule(schedule).patterns, patterns);
|
||||
assert.ok(validations.length <= maxValidations, validations.join('\n'));
|
||||
assert.equal(validations[0], `0 ${schedule}`);
|
||||
});
|
||||
}
|
||||
|
||||
for (const { schedule, next } of cases) {
|
||||
test(`cron-parser normalization preserves executions: ${schedule}`, async (t) => {
|
||||
const previousTZ = process.env.TZ;
|
||||
process.env.TZ = 'UTC';
|
||||
t.after(() => {
|
||||
if (previousTZ === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = previousTZ;
|
||||
});
|
||||
t.mock.timers.enable({
|
||||
apis: ['Date', 'setTimeout', 'setInterval'],
|
||||
now: new Date('2026-10-17T00:00:00Z'),
|
||||
});
|
||||
|
||||
assert.equal(isValidCronSchedule(schedule), true);
|
||||
assert.ok(parseCronSchedule(schedule).patterns.every(validate));
|
||||
assert.deepEqual(
|
||||
getInvalidCronSchedules({
|
||||
schedule,
|
||||
extra_schedules: [{ schedule }],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const actual = [];
|
||||
const job = createCronJob(
|
||||
schedule,
|
||||
(date) => actual.push(date.toISOString()),
|
||||
{ name: 'issue-3099', logger },
|
||||
);
|
||||
t.after(() => job.cancel());
|
||||
for (const [index, expected] of next.entries()) {
|
||||
t.mock.timers.tick(new Date(expected).getTime() - Date.now() - 1);
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(actual, next.slice(0, index));
|
||||
t.mock.timers.tick(1);
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(actual, next.slice(0, index + 1));
|
||||
}
|
||||
});
|
||||
}
|
||||
+1168
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user