Cherry Studio 定时任务机制选型指南:JobManager / SchedulerService / registerInterval / 原生 Timer 的决策树
【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio
导读
Cherry Studio 的主进程提供了三套「周期性或延时执行回调」的机制:JobManager、SchedulerService与BaseService.registerInterval,外加最底层的原生setInterval/setTimeout。选错机制正是 v2 统一化改造想要消灭的问题——散落的临时定时器没有可观测性、没有统一控制。本文以 scheduler-usage.md 的决策树为核心,结合 SchedulerService.ts、JobManager.ts、BaseService.ts 的源码实现,讲清四种机制的适用边界、触发器的生命周期语义与内部 ID 约定,帮助你为业务模块选出唯一正确的定时方案。
一图速览:四机制决策表
| 需求 | 应选机制 |
|---|---|
| 需要持久化、带状态机/重试/可观测性的周期性后台工作 | JobManager—registerJobSchedule() |
| 跨服务的 cron / interval / 一次性回调,无需持久化 | SchedulerService—registerSchedule() |
| 服务私有的一次性 GC / 自检 / 缓存清理,无外部可观测性需求 | BaseService.registerInterval() |
| 随运行时状态变化的定时器(协议心跳、流式 keep-alive) | 模块内部的原生setInterval/setTimeout |
背后的总原则:v2 统一化之前,项目里遍布无法观测、无法统一管控的临时定时器;统一化之后,每条重复性任务要么走 JobManager(持久化)、要么走 SchedulerService(瞬态),绝不允许私建并行调度器。而 JobManager 与 SchedulerService 的分层规则是:SchedulerService 只关心「何时触发回调」,对 Job 一无所知;JobManager 负责 Job 生命周期(注册表、持久化、六态状态机、分发、恢复),并反过来使用 SchedulerService 来武装调度。
决策树:按顺序回答三个问题
问题 1:任务是否需要跨进程重启存活(状态机 + 重试 + 取消)?
是 → JobManager。编写一个JobHandler注册后,调用:
application.get('JobManager').registerJobSchedule({ type: 'agent.task', trigger: { kind: 'cron', expr: '0 3 * * *', timezone: 'Asia/Shanghai' }, jobInputTemplate: { /* 每次触发时作为 Job 输入 */ }, catchUpPolicy: 'skip-missed' })获得的能力:持久化的调度行(jobScheduleTable)、下次进程启动时的自动恢复、重试退避、用户可见的状态、DataApi 列表查询、渲染进程进度钩子。注册返回{ id }(UUID),后续所有 by-id 控制 API(暂停/恢复/运行一次/删除)都以它为句柄。
从源码看,registerJobSchedule会先做三重校验(JobManager.ts):未注册 handler 抛JOB_UNKNOWN_TYPE;(type, name)重复抛JOB_SCHEDULE_NAME_CONFLICT;多实例类型省略name抛JOB_SCHEDULE_SINGLETON_EXISTS。校验通过后写入jobScheduleService.create再armSchedule。有关 Handler 的恢复/重试/catchUp/进度写法,参见 handler-authoring.md。
问题 2:任务是 cron 表达式触发,或跨多个服务的横切定时器?
是 → SchedulerService。直接调用:
const scheduler = application.get('SchedulerService') const disposable = scheduler.registerSchedule(id, trigger, callback)registerSchedule返回一个Disposable,disposable.dispose()即注销;服务onStop时也会自动清理。同一id重复注册会先停掉旧定时器再替换(SchedulerService.ts)。
三种触发器由判别联合Trigger描述(jobs.ts),字段与校验规则如下:
| kind | 字段 | 说明 |
|---|---|---|
cron | expr: string(必填,最小长度 1) | cron 表达式,由 croner 解析 |
timezone?: string | IANA 时区,经 Intl API 换算,缺省为本地时区 | |
limit?: number | 最多触发 N 次(映射 cronermaxRuns),适合试用/测试窗口 | |
interval | ms: number(必填,整数 ≥ 1) | 间隔毫秒数,上限见下文MAX_TIMER_DELAY_MS |
anchor?: 'createdAt' \| 'lastRun' | 间隔相位锚点 | |
once | at: number(必填,整数 ≥ 0) | Unix 毫秒时间戳,恰好触发一次 |
获得的能力:cron / interval / once 三种触发器统一 API;cron 支持 croner 的pause/resume/triggerNow;通过 Intl 正确处理时区;定时器全部unref(不阻塞进程退出);完全不碰 SQLite、无持久化。注意:SchedulerService 是无状态的,进程重启后一切归零,若在你的服务里直接调用它,必须在onReady中重新注册。
两个容易踩坑的源码细节:
- 定时器延迟上限:Node 的
setTimeout对超过2^31 - 1ms(约 24.8 天)的延迟会钳制为立即触发——这会让链式 interval 变成热循环、让远期 once 提前触发。因此validateTrigger会拒绝超界触发:interval 超过上限抛RangeError,once 距当前时刻超过上限同样抛RangeError(SchedulerService.ts)。registerSchedule内部也会先调用validateTrigger,非法触发直接抛错。 - cron 的 protect 语义:
scheduleCron构造 croner 实例时传了protect: true、maxRuns: trigger.limit、timezone,并用catch把回调异常记入日志(SchedulerService.ts)。protect: true意味着异步回调运行期间,下一次自然触发会被跳过而不是并发叠加——这只拦截重叠回调,不拦截外部调用者。
问题 3:任务是服务私有的一次性内部 tick(GC / 过期清理 / 刷新),无外部可观测性需求?
是 →BaseService.registerInterval()。它已经接入了生命周期:立即启动、unref、异步异常被捕获并记录(不会中断循环)、onStop/onDestroy时通过registerDisposable自动清除(BaseService.ts):
protected onInit(): void { this.registerInterval(() => this.sweepMyCache(), 5 * 60_000) }为什么不在这里用 SchedulerService?registerInterval是项目约定的「服务内部实现细节」写法。它把定时器所有权保留在服务内部——这正适合 GC / 自检这类回调,因为它们对其他模块毫无观察价值;放进 SchedulerService 反而让定时器更难推理。
问题 4(兜底):其余情况——随运行时状态变化的定时器
使用拥有模块内部的原生setInterval/setTimeout。经典案例是协议心跳:心跳间隔由服务端hello帧决定、可能在重连后改变。SchedulerService 的Trigger类型是刻意封闭的,以保持其 API 面最小——心跳不属于它。
这是一个有意识的设计边界,而非缺陷,理由有二(scheduler-usage.md):其一,SchedulerService 只接受声明式触发器(cron/interval/once),而心跳节奏由对端决定,本质上是状态机关注点,属于拥有模块自己;其二,若强行塞进 SchedulerService,就需要引入命令式 reschedule API,反而污染它简洁的表面。
常见错误清单
- 能用
registerInterval却去够 SchedulerService。SchedulerService 面向横切 / cron / 用户可见调度。一个服务只是「每 5 分钟清一次自家缓存」,就该用registerInterval;这里用 SchedulerService 毫无增益,还让定时器更难推理。 - 用原生
setInterval写 cron 节奏。「每天 03:00 在用户时区触发一次」是 croner 的菜。不要写86_400_000ms 的间隔——它会漂移且无视夏令时(DST)。 - 自建持久化调度表。项目只有一张:
jobScheduleTable,归 JobManager 所有。需要持久化就写 JobHandler。硬性约束:SchedulerService 是项目唯一的通用调度器——每条重复性任务只能通过 JobManager(持久化)或 SchedulerService(瞬态)到达时间,绝不允许私有并行调度器。 - 忘记 SchedulerService 无状态。它不跨重启存活。直接调用它的服务必须在
onReady重新注册。
Trigger 生命周期语义:once 与 interval 的微妙差异
SchedulerService.getNextRun(id)返回每种触发器的下一次自动触发时刻:cron 委托给 Croner;once 在定时器自清理前返回其配置的 epoch;interval 返回链式 timeout 的到期时间。当 interval 回调仍在运行时,下一次 timeout 尚未安装,查询会预测到期时间为now + interval,待回调落定后变为具体值。
三种触发器(cron/interval/once)在回调跨越期间的条目存续方式不同,且两种微妙之处都能从回调内部观察到。
once:先自清理,再调用
当once定时器触发时,SchedulerService在调用回调之前就从内部 Map 中删除了该调度条目。由此带来一个关键便利:回调内部可以用同一id重新注册调度而不冲突:
scheduler.registerSchedule('reminder.foo', { kind: 'once', at: Date.now() + 1000 }, () => { // 安全:在我们到达这里之前,旧条目已被移除。 scheduler.registerSchedule('reminder.foo', { kind: 'once', at: Date.now() + 5000 }, () => { /* ... */ }) })如果你需要「触发一次,之后可能再触发一次」的语义,这就是正路。注意:回调抛出异常时该调度 id 同样会被移除——从 SchedulerService 的视角,once永远是一次性的。源码对应scheduleOnce(SchedulerService.ts):先this.intervalHandles.delete(id)再await callback()。
interval:重武装前的安全检查
每 tick 结束后,SchedulerService 在重新武装下一个 interval 之前,会重新检查该调度条目是否仍在 Map 中。后果:回调可以同步调用scheduler.unregister(id),循环干净地停止,不会多出最后一记「游离 tick」:
scheduler.registerSchedule('healthcheck.foo', { kind: 'interval', ms: 30_000 }, async () => { if (await everythingIsTerminal()) { scheduler.unregister('healthcheck.foo') return // 不再有后续 tick。 } // ... })检查比较的是确切的 interval 条目(this.intervalHandles.get(id) !== entry),而不仅仅是map.has(id)。注销会停止旧循环;用同一 id 重新注册会把所有权转移给新条目,因此旧回调落定时无法重新武装或覆盖它。对应源码scheduleInterval(SchedulerService.ts):每次触发后先检查entry仍是当前条目,才setTimeout(fire, ms)链式续期,且全部unref。测试覆盖见 BaseService.test.ts 对registerInterval的异常隔离与自动清理断言,以及 JobManager.schedule.test.ts 对 once/interval/cron 调度生命周期的验证。
SchedulerService 内部 ID 约定
以下前缀归 JobManager 所有,第三方调用方应避免使用以防止冲突:
| 前缀 | 属主 | 用途 |
|---|---|---|
schedule:${scheduleId} | JobManager | 从jobScheduleTable武装的可重复调度 |
job:${jobId} | JobManager | delayed任务scheduledAt的一次性定时器 |
retry:${jobId}:${nextAttempt} | JobManager | 重试退避定时器(尝试序号防止同 jobId 冲突) |
这三类前缀均有源码佐证:JobManager 中暂停调度用scheduler.pause(\schedule:${id}`)([JobManager.ts](https://link.gitcode.com/i/d462ae9e49b6f945481ad87c318ce7e6#L719)),重试定时器 id 为 ``retry:${jobId}:${nextAttempt}``([JobManager.ts](https://link.gitcode.com/i/d462ae9e49b6f945481ad87c318ce7e6#L2055)),一次性 Job 定时器用 ``job:${snapshot.id}` ``(JobManager.ts)。
业务模块直接使用 SchedulerService 时,请选用带命名空间的 id(例如myservice.cleanup),避免与未来新增的 JobManager 前缀冲突。
结语:怎么选,一句话
需要持久化 + 状态机 + 重试 →JobManager;跨服务的 cron / interval / once 且可容忍重启丢失 →SchedulerService;服务私有的一次性自检 tick →BaseService.registerInterval;由运行时状态(如对端心跳帧)驱动的节奏 →原生定时器。按顺序过一遍三个问题,第一个命中「是」的就是答案。更多背景可继续阅读 overview.md(双服务架构与 DB 驱动分发)、concurrency-and-locks.md(四层锁模型)与 migration-checklist.md(存量服务迁移清单)。
【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考