8.1 KiB
ADR-0103:有界本机 Cron 调度与 Run 原子准入
- 状态:Accepted(本机 schedule state、一次性协调器、Profile cadence 与 Run 原子准入已实现;PostgreSQL/cluster 对等实现由 ADR-0104/0105 完成,物理设备长期抖动证据待完成)
- 日期:2026-07-22
- 关联 RFC:QL-RFC-0001 D-08、D-23、D-61、D-62、D-85、D-86、D-91、D-93、D-94、D-97、D-100、D-101、D-102
- 关联 ADR:ADR-0068、ADR-0071、ADR-0087、ADR-0092、ADR-0093、ADR-0094、ADR-0098、ADR-0102
背景
TaskDefinition、execution revision 与 Trigger 已成为不可变、摘要绑定的正式事实,Legacy adoption 也能在一个目标事务中发布这些事实,但此前没有任何生产 Scheduler 把到期 Trigger 转换为 Run。仅有 Trigger 表不等于执行接管;若直接在内存 cron callback 中启动进程,又会丢失重启恢复、幂等和并发围栏。
该切片还必须同时服务低配路由器与本机单节点。路由器不能为调度能力常驻大依赖、无界扫描或后台 watcher;单节点则必须在进程竞争和崩溃时保持同一到期时刻最多产生一个 durable Run。
Package 决策
不新增第 28 个 workspace package。Package 是部署、依赖或权限边界,不是每个 use case 的默认边界:
- Profile-neutral schedule contract、严格输入归一化和 cron 决策位于
@qinglong/runtime-core/local-scheduler显式子入口;根入口不 re-export,未使用者不会 eager-load。 - SQLite 状态与事务 authority 位于既有
@qinglong/local-sqlite的 runtime adapter。 - 一次性协调器与显式生命周期位于既有
@qinglong/local-execution/scheduler子入口;它们与 dispatch 总是共同部署,但仍保持源码依赖方向。 - application 只组合上述端口,不拥有 cron 计算、SQL 或进程启动语义。
croner@7.0.8 固定精确版本并只在首次 cron 计算时加载。选择它是因为离线 pack 约 124 KiB 且无运行时传递依赖;曾评估的 cron-parser 会带入约 4.5 MiB 的 Luxon,直接突破 4 MiB Edge 制品预算。制品审计会显式拒绝启动 import closure 中出现 croner,防止 lazy 边界回退。
持久化状态
SQLite migration 0035-local-scheduler 新增 QingLong3LocalTriggerSchedules,每个 Trigger head 恰有一行:
trigger_revision把状态绑定到当前 immutable Trigger revision;next_fire_at_ms是 durable cursor,NULL只表示需要初始化;last_scheduled_at_ms记录最后一次实际准入的 occurrence;state_version为 compare-and-set fence;- due 与 initialize 均有独立 partial index。
新建或追加内建 qinglong/cron@v1 Trigger 时,在同一事务计算并 upsert 下一时刻;extension Trigger 保留状态行但 next_fire_at_ms=NULL,在对应 scheduler provider 出现前不会进入内建 cron 候选。Legacy adoption 同样在原 publication transaction 内为每个已审 cron Trigger 写入非空 cursor,避免 100,000 行迁移后依赖低速懒初始化。
0036-capability-v18 把 local-control-core 推进到 contract v18,并显式声明 local_scheduler_admission。readiness 同时校验新表、两个索引、migration checksum、owned trigger 和精确 capability;不得通过自动建表或降低检查绕过 migration ceremony。
调度与 misfire 语义
协调器只提供显式 scheduleOnce(),核心层不创建 timer、watcher 或后台线程,因此单元测试、CLI 和未来 cluster lease scheduler 能复用同一轮次语义,而不会因 import 产生隐式常驻工作。@qinglong/local-execution/scheduler 另提供显式 start/stop 的 Profile lifecycle:Standalone 每 1 秒、Edge 每 5 秒执行至多一页,timer unref、轮次不重叠、诊断失败隔离,shutdown 等待受 5/10 秒硬上限约束。
application 只在 startup recovery、既有 execution lifecycles 与 admission 全部成功后启动 Scheduler lifecycle。shutdown 先撤销 admission,再停止 Scheduler,之后才 drain execution control、stack 与 storage,保证停止期间不会继续创建新 Run。
每轮只读取一页:Edge 为 4,Standalone 为 16,协议硬上限 256。所有时间、revision、摘要、cron、timezone 和页面形状先做严格验证。cron macro 和隐式主机 timezone 继续拒绝。
- 正常到期:准入当前 durable
next_fire_at_ms,随后把 cursor 推进到观察时刻之后。 skip:超过 Profile grace 后不创建 Run,只把 cursor 推进到观察时刻之后。fire_once:无论漏过多少 occurrence,只补偿最老的一个 durable occurrence,然后直接推进到观察时刻之后;不会扫描或回放 backlog。- 新建 Trigger 的首次 occurrence 从激活时间开始计算;migration 遗留的
NULL状态只初始化 cursor,不虚构历史执行。
Edge grace 为 30 秒,Standalone grace 为 5 分钟;全局最大值固定为 5 分钟。
原子 Run 准入
SQLite adapter 对每个 decision 使用 BEGIN IMMEDIATE,并在写入前重新读取 Project、Trigger head/revision、schedule version、Task revision 和 local execution revision。任一事实漂移都返回 raced,不产生部分 Run。
一次成功 admit 在同一事务内完成:
- 创建固定 Task revision/digest、Trigger revision/digest 和
scheduled_for_ms的 queued Run; - 创建
local_processclaimed Attempt; - 追加
run.created与run.queued两个事件; - 以 schedule revision/version/next cursor 作 CAS,更新 next/last/state version;
- commit 后才通知现有 bounded dispatcher 执行一次 dispatch。
幂等键为 ql3:cron:v1:<triggerId>:<triggerRevision>:<scheduledForMs>。Scheduler 不直接 spawn,不在 callback 中拼接命令,也不把 event delivery 或内存锁当成 durable claim。
资源与验收证据
- runtime-core 109/109、local-sqlite 54/54、local-execution 27/27、local-application 13/13 目标测试通过;覆盖 on-time、skip、fire-once、迁移初始化、严格上限、原子 Run/Attempt/Event、竞争重放、非重叠 cadence、诊断隔离和有界 shutdown。
- adoption 测试要求每个已发布 cron Trigger 在同一事务拥有非空 schedule cursor,并在失败路径一起回滚。
- 六类离线 production artifact 均通过 4 MiB、512 files、16 MiB RSS 门禁。最大为 standalone-application 2,181,590 bytes/323 files;base/adopted/application 启动闭包为 36/39/69 modules 且不 eager-load
croner,本轮抽样最大 RSS delta 为 12,042,240 bytes。 - ADR-0106 收敛后 importer 为 23;本切片没有新增 package、binary、watcher 或数据库连接。只有 active application 拥有一个显式、可停止、
unref的 Scheduler timer,base/adopted importer 仍无该 timer。 - 23 个 QL3 importer 全量测试和 backend 669/669 回归通过;依赖/源码边界审计保持零 finding。
未包含
- PostgreSQL schedule claim/lease 与 cluster Run admission 已由 ADR-0105 完成,不属于本 ADR 的 SQLite 证据范围;
- interval/event/webhook/AI Trigger provider;
- Project/Task 配额和 retry admission;
- 固定物理路由设备的长期时钟跳变、休眠唤醒、抖动、写放大及断电证据;
- Legacy scheduler cutover 与旧
ScheduleService.runTask下线。 - Scheduler 指标、积压告警与管理面健康投影。
在上述 Gate 完成前,本 ADR 只证明本机 cron occurrence 到 durable Run 的原子准入,不宣称 cluster scheduler 或 Legacy 全量接管。
ADR-0218 后续修正
ADR-0218 已 supersede 本 ADR 的两个实现细节,但不改变 misfire、CAS 或 Run 原子准入:
- Croner adapter 从
runtime-core移到现有local-execution部署 owner; - SQLite Trigger append 与 Legacy adoption 不再预计算非空 cursor,而与 PostgreSQL
一致写入
NULLsentinel,由首次 schedule cycle 基于原triggerUpdatedAtMs计算并执行 initialize/skip/admit。
因此上文“adoption transaction 写非空 cursor”及对应历史验收记录只描述当时实现, 不再是当前 contract。新的实现避免 base/adopted-only、Worker 与 cluster-admin 安装 Croner;物理 Profile 资源证据以 ADR-0218 后续重跑为准。