cron-engine 服务
1. 定位
cron-engine 是 Aalis 的共享定时引擎原语:把「cron 表达式 / 别名 / @every 间隔」解析为统一的订阅协议,所有周期型触发器(scheduler 任务、workflow 的 cron/interval 触发器)都挂接到它共享的一条整分钟 tick 上,而不是各自 setInterval。
- 服务注册名:
'cron-engine'(描述符cronEngine,消费方经cronEngine.use调用;packages/api-cron-engine/src/index.ts)。 - 契约包:
@aalis/api-cron-engine(订阅协议:接口 + 描述符cronEngine)。 - 表达式算法:
@aalis/util-cron(无状态纯函数,零依赖,与 Aalis 无关,可单独用)。 - 参考实现:
@aalis/plugin-cron-engine(packages/plugin-cron-engine/src/index.ts)。
它只负责定时与触发,不负责任务定义、持久化、权限或执行——那些属于上层(scheduler / workflow)。失败的 handler 仅记日志,不影响其他订阅者(packages/plugin-cron-engine/src/index.ts)。
2. 契约
2.1 服务接口 CronEngine
packages/api-cron-engine/src/index.ts:
export interface CronEngine {
// 订阅一个 cron / @every 表达式,返回 dispose 函数;失败抛 Error(建议先 validate)
subscribe(
expr: string,
handler: () => void | Promise<void>,
options?: CronSubscribeOptions,
): () => void;
// 表达式校验
validate(expr: string): ValidateResult;
// 从 from 起向前找下一次触发时间戳(ms);cron 在 lookaheadMinutes(默认 366*24*60)内未命中返回 null
nextFireTime(
expr: string,
from?: Date,
lookaheadMinutes?: number,
options?: CronSubscribeOptions,
): number | null;
}CronSubscribeOptions 只有一个可选字段 timeZone?: string——IANA 时区名(如 Asia/Shanghai)。空串或未传时使用进程本地时区;只对 5 字段 cron 生效,@every interval 与时区无关。
2.2 表达式类型与校验结果
CronExprKind 与 ValidateResult 定义在 @aalis/util-cron,由 @aalis/api-cron-engine 转出(消费者读懂返回值不必再依赖 util 包):
export type CronExprKind = 'cron' | 'interval';
export type ValidateResult =
| { ok: true; kind: CronExprKind; normalized: string; intervalSeconds?: number }
| { ok: false; text: string | TextRef };失败分支的 text 是给人看的原因:util-cron 的实现给它字典里的文字对象,第三方引擎交字符串也可以。交给模型与写日志的取 toSource;要抛给人看的错时,字符串用 Error,文字对象用 TextError。
validate(即 util-cron 的 validateCronExpr)是创建期的护栏:逐字段校验,任一字段解析为空集(如 abc、5-、超界单值)即拒绝,避免静默生成永不触发的死任务。@every 的间隔超过 MAX_INTERVAL_SECONDS(2147483 秒,约 24.8 天)同样拒绝:间隔交给 setInterval,延时超过 2^31-1 毫秒时 Node 按 1 毫秒处理,任务会每毫秒触发一次。
2.3 无状态纯函数(@aalis/util-cron,可独立 import,不必经服务)
表达式的解析与匹配是 POSIX cron 标准算法,与 Aalis 无关,故落在 util 层而非契约包——契约包只管订阅协议。scheduler / workflow / cron-engine 在编译期直接 import 它们做预处理与校验。下表函数与常量均定义在 packages/util-cron/src/index.ts:
| 函数 | 签名 | 用途 |
|---|---|---|
normalizeCronExpr | (input: string) => string | null | 别名(@daily 等)展开为 5 字段;@every 原样;不识别返回 null |
parseCronField | (field, min, max) => Set<number> | 解析单字段命中集合,支持 * */5 1-5 1,3,5 1-30/5 0/15 |
dateFieldsInTimeZone | (date, timeZone?) => {minute,hour,day,month,weekday} | 按时区拆 Date 为 cron 字段数字 |
matchesCron | (expr, date, timeZone?) => boolean | 判断某时刻是否命中(不处理 @every) |
parseEverySeconds | (input: string) => number | 把 @every 30s/5m/2h 解析为秒;不识别返回 0 |
validateCronExpr | (input) => ValidateResult | 见 §2.2 |
MAX_INTERVAL_SECONDS | number(2147483) | 间隔的上限(秒):setInterval 能排的最长延时折成的整秒数 |
validateIntervalSeconds | (seconds: unknown) => TextRef | null | 间隔秒数要是大于 0、不超过上限的数,不通过时给原因;@every 与 scheduler 的固定间隔、workflow 的 interval 触发器同一条 |
支持的表达式:5 字段标准 cron、别名 @hourly @daily @midnight @weekly @monthly @yearly @annually、以及间隔 @every Ns/Nm/Nh。
3. 谁提供 / 谁消费
提供方(唯一参考实现):@aalis/plugin-cron-engine
provides: [cronEngine](packages/plugin-cron-engine/src/index.ts)package.json双源对应aalis.service.provides: ['cron-engine'](packages/plugin-cron-engine/package.json)- 注册点
provide.use(cronEngine, service)(packages/plugin-cron-engine/src/index.ts)
消费方:
@aalis/plugin-scheduler:uses required = ['tools', 'cron-engine'](packages/plugin-scheduler/src/index.ts)。经业务句柄调用:cron 任务cronEngine.use.subscribe(jobCfg.cron, ..., tz ? { timeZone: tz } : undefined),并用cronEngine.use.nextFireTime(job.cron, new Date(), undefined, tz ? { timeZone: tz } : undefined)估算下次运行。它在initJob里先用parseEverySeconds把@every Ns折进自己的 interval 通道。@aalis/plugin-workflow:uses required = ['cron-engine'](packages/plugin-workflow/src/index.ts)。cron触发器cronEngine.use.subscribe(t.expr, ...)(packages/plugin-workflow/src/triggers.ts);interval触发器统一转成@every ${sec}s再 subscribe,避免与 scheduler 维护两份 setInterval(triggers.ts);定义时先用validate与validateIntervalSeconds校验cron、interval触发器,写错的不保存。
4. 写一个 provider
绝大多数作者不需要再写 provider——官方 @aalis/plugin-cron-engine 已是唯一实现,重复 provide 同名服务时,消费方只会按「本插件偏好 > 全局偏好 > priority > 注册顺序」选中其中一家(见 service-model)。仅当你要替换调度后端(例如换持久化的分布式定时)时才自行实现。
最小必须实现 = 接口三个方法 subscribe / validate / nextFireTime。可选 = timeZone 支持(不支持时建议忽略该参数并按本地时区评估,行为退化但不报错)。可直接复用 @aalis/util-cron 的纯函数完成校验与匹配,骨架如下:
import { cronEngine } from '@aalis/api-cron-engine';
import type { CronEngine, CronSubscribeOptions } from '@aalis/api-cron-engine';
import { definePlugin, lifecycle, provide } from '@aalis/core';
import { TextError } from '@aalis/schema-i18n';
import { matchesCron, normalizeCronExpr, validateCronExpr } from '@aalis/util-cron';
export default definePlugin({
name: 'my-cron-backend',
provides: [cronEngine],
uses: { provide, lifecycle },
apply({ provide, lifecycle }) {
const cronSubs = new Map<
number,
{ normalized: string; handler: () => void | Promise<void>; tz?: string }
>();
let nextId = 1;
const service: CronEngine = {
subscribe(expr, handler, options?: CronSubscribeOptions) {
const v = validateCronExpr(expr);
if (!v.ok) throw typeof v.text === 'string' ? new Error(v.text) : new TextError(v.text);
const id = nextId++;
if (v.kind === 'interval') {
const timer = setInterval(() => void handler(), (v.intervalSeconds ?? 0) * 1000);
return () => clearInterval(timer);
}
const tz = options?.timeZone?.trim() || undefined;
cronSubs.set(id, { normalized: normalizeCronExpr(expr) ?? expr, handler, tz });
return () => {
cronSubs.delete(id);
};
},
validate(expr) {
return validateCronExpr(expr);
},
nextFireTime(expr, from = new Date(), lookaheadMinutes = 366 * 24 * 60, options) {
const v = validateCronExpr(expr);
if (!v.ok) return null;
if (v.kind === 'interval') return from.getTime() + (v.intervalSeconds ?? 0) * 1000;
const tz = options?.timeZone?.trim() || undefined;
const start = new Date(from);
start.setSeconds(0, 0);
start.setMinutes(start.getMinutes() + 1);
for (let i = 0; i < lookaheadMinutes; i++) {
const c = new Date(start.getTime() + i * 60_000);
if (matchesCron(v.normalized, c, tz)) return c.getTime();
}
return null;
},
};
provide.use(cronEngine, service);
lifecycle.use.onDispose(() => {
cronSubs.clear();
});
},
});注册时 provide.use(cronEngine, service) 不需要 entryId(单 entry、无需 per-entry 拆分),priority 留默认 0 即可,要盖过官方实现取更高值(如 50)。双源同步:package.json 里也要写
"aalis": { "service": { "provides": ["cron-engine"] } }与代码里的 provides: [cronEngine] 一致(参考实现 package.json + index.ts)。详见 manifest-metadata。
实现要点(参照参考实现):
- 多订阅者共享一条对齐到整分钟的 tick:每轮 tick 结束时按「下一个整分钟边界」重排一次
setTimeout(scheduleNextTick,packages/plugin-cron-engine/src/index.ts),不用setInterval——后者的误差会累积,攒够一分钟就整分钟丢触发。runMinute对某个整分钟逐个matchesCron。 - tick 晚到要回补,且回补有上限:引擎记下已求值过的最后一个整分钟
lastTickMinute,每轮把(lastTickMinute, 当前整分钟]之间的每个整分钟各求值一遍——事件循环阻塞、机器休眠唤醒导致定时器晚到时不丢分钟。落后超过 5 分钟(MAX_CATCHUP_MINUTES)时只回补最近 5 分钟,其余分钟明确跳过并logger.use.warn一次说明跳过了多少分钟:合盖一夜醒来不该把几百分钟的任务一次性全轰出去。 - handler 必须包 try/catch,且对 Promise 返回值
.catch记日志——一个订阅者抛错不能影响其余(参考实现的runMinute与 interval 分支各有同步/异步两条 catch)。 subscribe里若给了timeZone,应提前new Intl.DateTimeFormat({ timeZone })探测合法性并抛「非法时区」(参考实现抛的是TextError),不要拖到分钟 tick 才静默失败。onDispose清空所有 timer 与订阅表。
5. 标准消费方式
- 在
uses required声明'cron-engine',让运行时保证服务就绪后才apply(scheduler / workflow 的uses required)。 - 经业务句柄
cronEngine.use调用。句柄是活的,选择、缺席与固定同一家的规则见 选择层与业务层。 - 创建前先
validate或捕获subscribe的抛错——参考实现两个消费方都用try/catch包住 subscribe 并logger.warn,避免一条坏表达式中断整批注册(schedulerinitJob、workflowtriggers.ts)。 - 保存好
subscribe返回的 dispose,在任务删除/禁用/插件卸载时调用(scheduler 存rt.cronDispose、workflow 存cronDisposersMap)。 - 可选依赖:若你的插件在没有 cron-engine 时仍能降级运行,则不要放进
required,改为运行时先看cronEngine.present再调用。
@every Ns 与 5 字段 cron 都可直接交给 subscribe——无需自己预 normalize(scheduler initJob 注释)。interval 语义统一委托 @every Ns 表达式,不必再自行 setInterval(workflow triggers.ts 注释)。
6. 能力 / 风险 → 影响
cron-engine 本身不触及 authority、storage、网络——它只回调 handler。安全责任完全落在 handler 内部和上层:
- 触发没有人类调用者。cron/interval/event 触发器没有触发人,workflow 以定义者作调用者,没有定义者的按匿名,只能跑 public 工具,杜绝借他人 workflow 提权(见 plugin-workflow)。你的 handler 若要执行带风险的动作,必须自己经 authority 闸,且不能假冒 owner 身份。
- handler 隔离:provider 保证单个 handler 抛错不波及其他订阅者,但 handler 自身不得阻塞——它跑在共享 tick 上,长任务应
void/fire-and-forget(scheduler 与 workflow 都是异步 fire,不阻塞 tick)。 - 风险标注的副作用属于上层工具调用,不在本服务范畴;定时只是触发器。
7. 边界情形与注意事项
- 最小粒度是分钟:cron tick 对齐整分钟、
runMinute只按整分钟求值(packages/plugin-cron-engine/src/index.ts),秒级 cron 不支持;要更细只能用@every Ns(走独立setInterval)。 - 回补只在进程活着时发生:休眠唤醒 / 长阻塞后的补跑上限 5 分钟,且同一分钟只补一次(不会重复触发);handler 因此可能在整分钟之后几秒甚至几分钟才被调用,别把「被调用的时刻」当成表达式命中的时刻。
- 墙钟回拨按当前时刻重锚:若 tick 发现当前整分钟早于已求值过的最后一分钟(NTP 校正 / 手动改表),引擎 warn 一条「检测到墙钟回拨,按当前时刻重锚,回拨跨过的分钟不补跑」并把锚点退到当前分钟的前一分钟——当前这一分钟照常求值一次,回拨跨过的分钟不做补跑(也不会因锚点停在未来而静默停摆);注意墙钟重新走过那段时间时命中的分钟会再触发一次,引擎不对此去重。
@every不参与 cron 匹配也不认时区:matchesCron/normalizeCronExpr对@every直接放过/原样返回(packages/util-cron/src/index.ts),间隔从注册时刻起算、timeZone对它无意义。nextFireTime有上限:cron 在lookaheadMinutes(默认 366×24×60 ≈ 一年零一天)内没命中就返回null,极稀疏的表达式(如2/30月)可能落空。- 进程内、非持久化:所有订阅都是内存 timer,进程重启即丢;持久化任务定义、重启恢复属上层(scheduler/workflow 各自落盘后重新
subscribe)。进程停机期间错过的触发不会补跑(回补只覆盖进程活着但 tick 晚到的情形,且上限 5 分钟)。 day与weekday取交集而非并集:matchesCron用&&串联五个字段(packages/util-cron/src/index.ts),与 Vixie cron「日/周谁限定取并集」的传统行为不同——同时写日和周会变成「既是某日又是某周几」才触发。- 字段越界被静默夹取:
parseCronField把范围夹到[min,max](packages/util-cron/src/index.ts),如分钟字段1-100不会塞入 60-99;但整字段解析为空集会在validate阶段被拒,不会生成死任务。 午夜 hour=24的修正:用 IANA 时区且hour12=false时 Intl 午夜可能返回"24",已换回0以匹配 cron 0-23(dateFieldsInTimeZone,packages/util-cron/src/index.ts);自实现 provider 需一并处理此归一。
8. 交叉链接
- 概念:service-model(DI 按名选择 / priority / entryId)、lazy-service-access(业务句柄是活的)、manifest-metadata(
provides双源)、security-model(触发身份与 authority)。 - 核心:core/service、core/context、plugins/plugin-authority、core/events(workflow 经
trigger:fired/events.use.on串联事件触发)。 - 服务:services/tools(被触发的执行单元)、上层的 scheduler / workflow 插件即本服务的两个标准消费方。