32 KiB
ADR-0002:Legacy Crontab 兼容、影子写入与切换策略
- 状态:Proposed
- 日期:2026-07-18
- 决策者:QingLong Maintainers
- 关联 RFC:QL-RFC-0001
- 前置决策:ADR-0001
- Amended by:ADR-0445、ADR-0446、ADR-0447
1. 决策摘要
QingLong 3.0 采用按执行来源渐进切换的三态兼容模式:
off -> shadow -> primary
- off:只执行 2.x 路径,不创建 Run。
- shadow:2.x 的 Crontab、RunningInstance、Shell 回调和日志仍是用户可见事实源;系统旁路创建 Run、RunAttempt 和 RunEvent,用于验证,不参与调度与结果判定。
- primary:Run 聚合成为事实源,2.x 字段变为兼容投影;所有外部副作用由新 Runtime 发起,Legacy 路径不得再次执行同一任务。
切换按已被入口事实证明的 manual、scheduled、boot 与内部任务 execution origin 独立进行,不允许一次性全局切换。once、grpc 等保留值只有在未来具备独立
trigger/admission identity 后才形成切换单元,不能由 schedule 或 transport 名称推导。任何单次执行在被接受时即固定 owner 为 legacy 或 runtime,运行中不得切换执行引擎。
Shadow 写入必须 fail-open:新模型写入失败只产生有界日志、指标和对账记录,不改变 2.x 的执行结果。Primary 路径在产生 spawn、派发或其他外部副作用前必须 fail-closed;只有能够证明外部副作用尚未发生时,才允许受控回退到 Legacy。
新 Runtime 通过 run_id、attempt_id、短期 callback token 和 dedupe key 关联状态。Cron ID、PID 和 log path 只用于 Legacy 兼容与诊断,不能作为 3.0 状态关联主键。
2. 背景
2.1 当前执行链事实
当前实现存在多条执行入口,但最终大量依赖 Shell 回调更新状态:
- PUT /crons/run 调用 CronService.run,先把多个 Crontab 标为 queued,再异步调用 runSingle。
- runSingle 经进程内并发限制后 spawn task 命令,并把 Crontab 更新为 running、记录 PID 和 log path。
- system crontab 与 Node scheduler 都执行带 ID 的 Shell 命令;Node scheduler 外层的 runCron 本身不负责最终状态。
- task.sh/share.sh 在开始和结束时调用 /open/crons/status。
- CronService.status 在 running 回调时创建 RunningInstance,在 idle 回调时按 cron ID、PID 和状态更新实例,并更新 Crontab 的最近状态。
- CronService.stop 按 Crontab PID 和命令查杀进程,随后把活跃实例标记为 stopped,并把 Crontab 恢复为 idle。
- 为降低旧执行的迟到回调覆盖新执行,现有代码在 log path 不匹配时跳过部分 Crontab 字段更新,但仍没有稳定的运行级关联键。
关键代码位置:
back/api/cron.ts
back/services/cron.ts
back/services/schedule.ts
back/shared/runCron.ts
back/schedule/addCron.ts
shell/api.sh
shell/share.sh
2.2 现有模型的能力边界
Crontab.status 只表达任务最近状态,无法准确表示同一任务的多个并发实例。RunningInstance 提供实例记录,但仍存在以下限制:
- 记录在 Shell running 回调到达后才创建,spawn 与记录之间有空窗。
- 重复 running 回调可能创建重复实例。
- Cron ID、PID 和 log path 无法形成跨重启、跨节点、跨 Attempt 的稳定身份。
- queued 没有独立持久化实例,服务重启时难以恢复。
- stop 与自然退出并发时,迟到回调可能再次修改 Legacy 状态。
- Crontab 修改后无法说明历史执行使用的是哪个任务版本。
因此不能让 Legacy 状态和 Run 状态长期双向互相驱动,否则会形成循环覆盖和不可解释冲突。
3. 目标
- 在不改变 2.x API、CLI、Shell 和常用脚本行为的前提下验证新 Run 模型。
- 每个执行只由一个 owner 产生外部副作用,避免双跑。
- 允许按执行来源小步切换和快速停止扩大影响面。
- 对重复、乱序、迟到和缺少关联 ID 的回调给出确定行为。
- 允许 Shadow 与 Legacy 状态自动对账,并量化差异。
- Primary 模式下继续为 2.x UI/API 提供可接受的兼容投影。
- 保持 edge 模式的写入、内存和后台任务开销有界。
4. 非目标
- 本 ADR 不决定 Executor 接口和进程隔离细节。
- 本 ADR 不决定 SQLite/PostgreSQL Repository 的具体驱动。
- 本 ADR 不把历史 RunningInstance 自动伪造成完整、可信的 RunEvent 历史。
- 本 ADR 不在 Shadow 阶段替换现有 UI、日志 API 或停止 API。
- 本 ADR 不承诺把 Crontab 的单值状态无损映射为并发 Run 状态。
- 本 ADR 不允许双执行后再通过结果去重来弥补 owner 不明确。
5. 术语
5.1 Execution origin
触发执行的兼容来源:
manual
scheduled_system
scheduled_node
once
boot
grpc
subscription
system
script
Runtime 实现可以归并内部枚举,但必须保留足够信息区分人工、定时和内部任务。
5.2 Execution owner
单次执行的唯一拥有者:
- legacy:现有 CronService、scheduler 或 Shell 链负责执行。
- runtime:RunService、RunQueue 和 Executor 负责执行。
owner 在接受触发时写入执行上下文,并贯穿日志、指标和回调。Feature Flag 后续变化不影响已接受的执行。
5.3 Compatibility projection
从 Run/Attempt 派生到 Crontab、RunningInstance 和 2.x response 的有损视图。Projection 不是 3.0 事实源。
6. 模式与事实源
| 模式 | 外部副作用 owner | 用户可见事实源 | Run 数据用途 | 新模型失败行为 |
|---|---|---|---|---|
| off | Legacy | Legacy | 不创建 | 不适用 |
| shadow | Legacy | Legacy | 旁路验证与对账 | fail-open |
| primary | Runtime | Run/Attempt/Event | 调度、状态、审计 | 副作用前 fail-closed |
同一 origin 在任一时刻只能处于一个模式。配置加载失败或出现未知值时回退到 off,不能默认进入 primary。
7. Feature Flag 模型
建议配置结构:
type CompatibilityMode = 'off' | 'shadow' | 'primary';
interface RuntimeRolloutConfig {
defaultMode: CompatibilityMode;
origins: Partial<Record<ExecutionOrigin, CompatibilityMode>>;
allowLegacyFallbackBeforeStart: boolean;
}
约束:
- 具体 origin 配置覆盖 defaultMode。
- 3.0 Alpha 初始默认全部为 off。
- primary 只有在 migration、状态机、Executor contract 和对账门禁通过后才可配置。
- 配置变更必须写审计日志,包含 actor、旧值、新值和时间。
- edge 不启动远程配置 watcher;配置刷新使用已有配置机制或显式 reload。
- 禁止使用“数据库写失败就自动把全局模式改为 Legacy”这种隐式降级。
7.1 next Alpha Shadow 开关
当前孵化实现只开放观察型 Shadow,不通过该环境变量提供 primary:
QL3_SHADOW_ORIGINS=manual,scheduled_node,scheduled_system,boot,subscription,system,script
- 未设置或设置为空时全部为 off。
- ADR-0447 后当前接受
manual、scheduled_node、scheduled_system、boot、subscription、system与script;未知 origin 被忽略并记录 有界配置告警。once与grpc仍是保留领域值:现有@once只是 schedule 标记,gRPC 只是传输入口,两者都不能单凭该字段推导 execution origin。 - 配置在进程内首次使用时读取;edge 不启动 watcher,变更后需要通过既有进程重启或未来的显式 reload 生效。
- 兼容观察器在实际 HTTP/gRPC worker 中按需加载;关闭时不构造 Shadow 事实或任务摘要、不增加 ChildProcess 监听器、不初始化 Repository、不创建后台任务,也不引入额外数据库写入。
manual/scheduled_node/boot/subscription/system/script只监听 Legacy 已创建的同一个 ChildProcess。scheduled_system不持有 Node ChildProcess, 只接受 system crond 显式标记后由 Shell start/finish 共用的稳定 execution ID;finish-only 回调可以幂等补齐 accepted→terminal 聚合。Shadow 代码不得调用 Executor 或第二次 spawn;subscription/system/script仅在ScheduleService已选中执行且onBefore成功后 accepted。- 任意初始化、接受或后续写入失败都退化为 no-op,只记录不含命令、环境变量和 Secret 的稳定错误类型与有界计数。
bootTask只把启用的@boot条目以固定bootorigin 交给runSingle;普通 HTTP/gRPCrun仍使用manual。@once不会因 schedule 字符串被改记为once,gRPC 请求也不会因 transport 被改记为grpc。
该环境变量是 Alpha 兼容桥,不替代最终可审计的 RuntimeRolloutConfig。进入 primary 前必须改用具备配置校验、审计和 owner 固化语义的正式配置面。
7.2 next manual Primary manifest 门禁
孵化实现已提供严格的 data/config/qinglong3-rollout.json loader,并在 HTTP worker 接入默认关闭、按需加载的 manual Primary bootstrap:
- 文件缺失、不可读、超过 64 KiB、JSON 非法、字段未知、审批过期或 gate 缺失时返回 off,不构造或安装 router。
defaultMode固定为 off,当前只允许origins.manual;Primary 不得通过环境变量、通配 origin 或隐式默认值开启。- enabled manifest 必须记录 revision、approvedBy、approvedAtMs、expiresAtMs 和 rollbackPlanRef;审批窗口最长 30 天。
durableCancellation、startupReconciliation、atomicLegacyProjection、rollbackDrill、edgeBudget必须全部为 passed。- 审计只包含 source path/hash、revision、时间和稳定判定。接受审计先于 router 安装;安装后审计失败立即调用 disposer,恢复原 owner。
- edge 不启动 watcher;当前 bootstrap 只在 HTTP worker 启动时读取一次,未来显式 reload 必须复用同一校验和审计边界。
bootstrap 接入不代表 Primary 已默认开放。文件缺失、disabled、rejected 或 manual 非 primary 时保持 Legacy,且不加载完整 Runtime stack、不创建 router 或 timer。只有 accepted 且全部 gate 通过的 manifest 才按 startup reconciliation、cancel lifecycle、router 的顺序激活;任一步失败都会撤销 router 并停止 lifecycle。ADR-0007 定义的 completion/log supervisor、固定 edge 基准、部署配置写入与用户可见状态、操作回滚演练仍需再次评审。
8. Legacy 到 Run 的身份映射
8.1 Task 身份
在 TaskDefinition 完成迁移前:
- legacy_cron_id 保存 Crontab 数字 ID。
- task_id 使用稳定的兼容 namespace,例如 legacy-cron:。
- task_revision 由影响执行的字段生成稳定摘要,至少包括 command、schedule、task_before、task_after、work_dir、log_name、环境引用版本和 Package/Subscription 来源。
- Run 创建后保存 revision 或不可变 snapshot 引用;Crontab 后续修改不得改变历史 Run。
不得把 Run 外键设置为随 Crontab 删除级联删除。任务删除后,历史 Run 仍须可查询并展示保存的名称和摘要。
8.2 一次触发对应一个 Run
- 批量手动执行多个 Cron 时,每个 Cron 创建独立 Run。
- 同一个 Cron 的多实例执行各自创建独立 Run。
- API 批量请求可以使用 request_id 关联,但不创建共享生命周期的“批量 Run”。
- 自动重试在同一 Run 下创建新 RunAttempt;用户再次点击运行创建新 Run。
8.3 Trigger 信息
在 Trigger 表落地前,Run 仍需记录:
trigger_type
triggered_by
scheduled_for_ms nullable
request_id nullable
legacy_schedule nullable
这些字段或受限 payload 不能包含 Secret 和完整环境变量值。
9. Shadow 写入规则
9.1 触发时
Legacy owner 接受执行后,Shadow Adapter 尝试:
- 创建 Run,初始状态为 created。
- 追加 run.created。
- 当 Legacy 进入队列时转换为 queued 并追加 run.queued。
- 创建 attempt 1,用于关联后续 spawn 与回调。
Shadow Adapter 不得:
- 改变 Legacy 是否执行。
- 等待无界重试后才返回 Legacy 请求。
- 获取会改变 Legacy 并发顺序的长时间锁。
- 把 Shadow Run ID 暴露成 2.x API 的成功必要条件。
9.2 启动和结束映射
| Legacy 事实 | Shadow 事实 |
|---|---|
| Crontab queued | Run queued |
| spawn 已获得 PID | Attempt starting;记录本地 executor handle |
| Shell running callback | Attempt running;Run running |
| Shell idle,exit code 0 | Attempt succeeded;Run succeeded |
| Shell idle,exit code非 0 | Attempt failed;Run failed |
| 用户 stop 已接受 | Attempt/Run cancelled,并记录 kill 结果 |
| 进程消失且无结束回调 | Attempt lost;由协调器决定 Run 结果 |
Shadow 转换仍必须遵守 ADR-0001。无法合法映射时追加 compat.transition_mismatch,不能强行覆盖终态。
当前 Alpha 切片对 manual 与 scheduled_node 直接观察 Node ChildProcess 的 spawn、error 和 exit 事件,因此不依赖 Shell callback 才能形成基本终态。下述两级关联已补充 Shell callback、stop/cancel 和乱序/迟到回调,但启动后协调与差异对账仍是进入 Primary 前的门禁,不能由进程内观察能力替代。
9.4 next Alpha callback 与 stop 关联
当前实现已为 manual 与 scheduled_node 增加两级、失败开放的兼容关联:
- 同一 worker 内使用最多 256 条 active execution 的内存注册表,保持 stop/callback 与 ChildProcess exit 的入队顺序;终态、启动失败和取消后立即移除,不启动清理定时器。
- callback 或 stop 落到其他 worker 时,按
legacy_cron_id + enabled origins + legacy owner + active status查询持久化候选;单次最多读取 64 个,超限只处理有界集合并记录 truncated,不能无界扫描。 - 单实例匹配依次使用 opaque log artifact、PID 和唯一候选兜底。日志与 PID 各自唯一但指向不同 Run 时视为冲突;零候选、多候选和冲突都只记录 unmatched/ambiguous,不猜测更新。
- log path 只在进程内转换为不超过 36 字符的稳定摘要;原始路径、命令和环境变量不进入关联告警。
- stop all 取消该 Cron 的全部有界 active Shadow Run;stop instance 只在强字段或唯一候选能够确定一个 Run 时取消。
- 取消事实在 Legacy kill 前投递;同 worker 的后续 exit 排在取消之后。跨 worker 使用持久化定位器尽力关联,任何查询或写入失败都不能阻断 kill 或改变 2.x API 响应。
- 乱序 finished 可以从 queued/claimed 补齐 dispatching、starting、running 和终态;重复终态 callback、取消后的迟到成功 callback 不覆盖终态,也不追加重复完成事件。
这仍不是完整 Reconciler:当前没有启动后批量扫描、缺失事实修复、公开指标采集器或 Shadow/Legacy 差异报表。上述能力必须在 Primary 门禁前补齐,但 edge 默认不得因此增加常驻 watcher 或无界内存队列。
9.3 Shadow 写失败
Shadow 写失败时:
- Legacy 执行继续。
- 写结构化错误日志和有界计数指标。
- 若已经存在 Run,尽力追加 compat.shadow_write_failed;数据库不可用时不做无界内存缓存。
- 对账器可以在数据库恢复后标记缺失或不完整记录,但不得伪造未知的精确时间和事件顺序。
- Shadow 完整率低于门禁时禁止进入 primary。
10. Primary 执行规则
10.1 触发接受
Runtime owner 必须先完成以下持久化事务,再进入队列或 spawn:
- 创建 Run 和 run.created。
- 将 Run 转换为 queued 并追加 run.queued,或以等价的单事务命令完成。
- 写入唯一 idempotency key 或 trigger delivery key。
- 确认事务提交成功。
事务失败时不得 spawn。API 返回稳定错误,定时触发记录 delivery failure 并按 Trigger 策略重试。
10.2 外部副作用边界
以下任一事件发生后都视为外部副作用可能已发生:
- 调用 Executor.start。
- spawn 返回结果未知或超时。
- 已向 Worker 发送可接受的派发请求。
- 已把执行消息提交到外部队列。
进入该边界后禁止自动回退到 Legacy,因为无法证明不会双跑。系统必须把 Attempt 标记为 failed 或 lost,并交由协调器处理。
10.3 有限 Legacy 回退
只有同时满足以下条件,才允许 allowLegacyFallbackBeforeStart:
- 错误发生在外部副作用边界前。
- 该执行尚未创建 claimed/starting Attempt。
- idempotency key 明确属于本次触发。
- 回退决策写入审计日志;若 Run 数据库可用,追加 compat.legacy_fallback。
- Legacy 接受同一 request/delivery key,避免重复回退。
默认值为 false。Beta 前是否保留该选项由运行数据决定。
11. 回调关联与幂等性
11.1 新回调信封
Primary Runtime 启动 Shell 时注入:
QL_RUN_ID
QL_ATTEMPT_ID
QL_CALLBACK_TOKEN
QL_CALLBACK_SEQUENCE
推荐回调信封:
interface AttemptCallback {
runId: string;
attemptId: string;
token: string;
sequence: number;
event: 'started' | 'heartbeat' | 'finished';
pid?: number;
logRef?: string;
exitCode?: number;
occurredAtMs: number;
}
每个回调生成稳定 dedupe key,例如 <attempt_id>::。重复回调返回已提交结果;小于已提交序列的迟到回调只记录受限诊断,不修改终态。
11.2 Legacy 回调
不包含 Run/Attempt ID 的旧回调:
- 在 off/shadow 模式继续更新 Legacy 状态。
- Shadow Adapter 可以按 cron ID、PID 和 log path 尝试关联,但只有唯一候选时才更新 Shadow Run。
- 存在零个或多个候选时记录 compat.unmatched_callback,不能猜测。
- 在 primary 模式,新 Runtime 发起的执行必须携带新信封;缺少信封视为协议错误。
- 非 Runtime 发起的旧任务仍按其固定 owner 走 Legacy,不得写入任意 Primary Run。
11.3 Token
Callback token:
- 仅授权更新一个 Attempt。
- 使用高熵随机值,数据库保存哈希或可轮换验证材料。
- 有明确失效时间,Attempt 终态后只允许幂等重放已接受事件。
- 不写入普通日志、RunEvent payload 或进程列表可见的命令参数;优先通过受限环境或本地凭据文件传递。
12. Compatibility Projection
12.1 单向投影
数据方向固定:
shadow: Legacy -> Run shadow facts
primary: Run facts -> Legacy projection
禁止同一 execution 同时启用两个方向,禁止用 Crontab watcher 反向覆盖 Primary Run。
12.2 Crontab 投影
Primary 模式下,2.x 字段按以下规则投影:
| Legacy 字段 | 投影规则 |
|---|---|
| status | 有 active Run 时按 running 优先于 queued;无 active Run 时为 idle |
| pid | 最近启动且仍 active 的本地 Attempt PID;远程执行为空 |
| log_path | 最近用户可见 Run 的兼容日志路径 |
| last_execution_time | 最近 Run started_at,保持 2.x 时间单位契约 |
| last_running_time | 最近终态 Run 的 duration 投影 |
Crontab 无法表达 succeeded、failed、cancelled 和并发实例,因此不能用于 3.0 结果查询或恢复。
12.3 RunningInstance 投影
- 每个本地或可表示的 Attempt 投影一个 RunningInstance。
- 保存稳定的 run_id 和 attempt_id 扩展列后,更新必须按这些 ID 完成。
- 旧 API 返回结构保持兼容,新增字段必须是可选字段。
- 远程 Worker 没有可用 PID 时 PID 为空,停止由 Executor handle 完成。
当前 0003-running-instance-run-reference migration 已增加 nullable run_id、attempt_id,建立 (run_id, started_at) 查询索引与 attempt_id 唯一索引。旧行保持 null,旧 API 无需提供新字段。孵化实现已经增加 Primary 专用的组合 Repository:它跟踪同一事务内变更的 Run/Attempt,在提交前按 attempt_id 幂等投影 RunningInstance,并按“running 优先于 queued、无 active Run 时 idle”聚合 Crontab。任一投影参与者失败时,Run、Attempt、Event 与 Legacy projection 一起回滚;Shadow Repository 不注册该参与者。
12.4 事务边界
当 Run、Event 和 Legacy projection 位于同一控制面数据库时,状态转换和必要的兼容投影应在同一事务完成。投影失败不得提交一个对 2.x API 不可解释的 Primary 状态。
若未来 projection 跨存储,必须使用 transactional outbox 和幂等消费者;不能在事务提交后仅做一次 best-effort 更新。
12.5 next manual Primary 孵化装配
当前 manual 入口已经具备一个默认关闭的 owner seam:未安装 ManualPrimaryExecutionRouter 时继续执行原 Legacy 路径;显式安装且 rollout policy 将 manual 判定为 primary 后,runSingle 只调用 Runtime,不再创建 Legacy ChildProcess。真实本机装配由 Primary 专用组合 Repository、LocalProcessExecutor、受限日志适配器和 ManualPrimaryRuntime 组成;严格 manifest loader 与可回滚 assembly 也已存在,但生产 boot 不调用安装入口,因此仍不可由部署配置启用。
约束:
- boot 虽复用
runSingle,不参与 manual owner seam。 - Runtime 一旦被选为 owner,准备日志、创建 Run 或 Executor 启动失败均 fail-closed,禁止回落到 Legacy 再次 spawn。
- 新触发 owner 与 in-flight owner 分离;关闭新触发不应卸载仍持有 active handle 的 Runtime。
- stop all/stop instance 优先按进程内 Run/Attempt owner 调用 Executor。数据库中存在
attempt_id、但当前 worker 无法证明 ownership 时拒绝 PID-only stop,等待跨 worker cancel 或 Reconciler 能力。 - 该装配不等于 Primary rollout Gate 已通过;正式启用仍需要可审计配置、跨 worker cancel、重启后 supervisor、固定 edge 预算和回滚演练。
13. Stop 与迟到退出
13.1 Shadow 模式
Legacy stop 行为不变。Shadow Adapter 观察 stop 结果:
- 能唯一关联时,将对应 Attempt/Run 转为 cancelled。
- Shell 后续 exit callback 只能追加 late callback 诊断,不能把 cancelled 改为 succeeded/failed。
- kill 失败与状态更新失败分别记录,不把“数据库已 stopped”等同于“进程一定已退出”。
13.2 Primary 模式
- Cancel command 在同一事务读取 Run/Attempt,使用 Run version/CAS 写入首次
cancel_requested_at_ms、受限 reason 和run.cancel_requested;此时不提前把实际进程标记为已退出。 - 事务提交成功后才允许 Executor 执行 cancel/kill;提交失败不得发送 signal。
- 重复取消不追加第二个请求事件,但可以幂等重试 stop;stop 失败保留 durable request,由 Reconciler 继续检查。
- Attempt 终态先提交时,取消返回 already-terminal 且不发送 signal;取消请求先提交时,迟到 exit code 0 或其他非 cancel 完成结果收敛为 cancelled。
- 实际完成后 Attempt/Run 转为 cancelled,并由同事务 Projection 更新 RunningInstance;kill 结果和后续核验事件不得包含原始命令、环境或 Secret。
2.x /crons/stop 仍可返回兼容响应,但内部语义是异步 cancel,不承诺响应时进程已退出。
当前 manual 孵化实现已覆盖独立 durable cancel command、stop-before-signal、首次请求幂等、cancel/complete 获胜裁决、待取消 Run 的有界恢复查询、最多 64 条一页的 cross-worker source、独立 dispatch lease/fencing Repository、指数退避、低敏结果 Event、单周期有界 supervisor、Linux durable handle 身份复验与 TERM/KILL controller、进程内 active handle stop 路由、Attempt 终态投影与 stop/prepare 竞争时的 abort。HTTP worker 已接入轻量、默认不激活的 manifest bootstrap:只有 accepted 且 manual=primary 才惰性加载真实 stack,先完整有界 Reconcile,再启动 cancel lifecycle 和安装 router;失败自动撤销,shutdown 先停止 router/lifecycle。ADR-0007 的 completion/log supervisor 仍未完成;找不到可证明的 owner/handle 时不会用 PID 猜测补偿。
14. 修改、删除与历史
14.1 运行中修改 Crontab
Run 使用创建时的 task revision/snapshot。更新 Crontab 只影响后续 Run,不修改正在排队、运行或已结束的 Run。
14.2 删除 Crontab
- 删除不级联删除 Run、Attempt、Event、日志或审计记录。
- active Run 默认继续使用 snapshot;若产品选择“删除即取消”,必须作为显式、可审计命令实现。
- v2 API 删除响应不因历史 Run 保留而改变。
14.3 历史导入
3.0 首次升级不自动把所有 RunningInstance 伪造成完整 Run。可以提供独立、可重跑的 import 工具,生成标记为 legacy_import 的只读记录,并明确:
- 原始时间精度和状态可能不完整。
- 不生成没有证据的中间事件。
- import 记录不参与调度、重试和资源计费。
15. 启动与恢复
15.1 off/shadow
Legacy 启动行为暂时保持。Shadow Reconciler 在有界批次内扫描非终态 Shadow Run:
- 根据唯一 RunningInstance/PID/log path 证据更新。
- 无法确认时标记 Attempt lost 或写 mismatch,不把 Legacy 任务强制改状态。
- edge Profile 默认低频、有限批次运行,禁止全表高频扫描。
15.2 primary
启动时不得像 2.x 一样把所有 Run 批量重置为 idle。系统按 ADR-0001 协调 dispatching/running/lost Attempt,并通过 Executor handle、Worker lease 和 callback 序列恢复。
16. 对账与可观测性
16.1 对账维度
Shadow 阶段至少记录:
shadow_run_create_total
shadow_run_create_failed_total
shadow_callback_unmatched_total
shadow_transition_mismatch_total
shadow_terminal_mismatch_total
shadow_transition_lag_ms
shadow_active_run_delta
legacy_fallback_total
标签必须有界,只允许 origin、profile、executor type 和错误分类;禁止使用 cron ID、run ID、command 或用户名作为指标 label。
16.2 终态对账
终态对账比较:
- Legacy RunningInstance status/exit code。
- Shadow Run/Attempt terminal status/exit code。
- start/finish 时间是否在允许误差内。
- 日志引用是否存在。
- 是否发生重复执行或缺失 Run。
差异记录保存受限摘要和关联 ID,不复制完整命令、环境变量或日志内容。
17. Rollout 顺序
1. Schema only
2. manual shadow
3. scheduled_node shadow
4. scheduled_system shadow
5. boot shadow
6. future source-proven trigger shadow(只有独立 once/grpc trigger 存在时)
7. manual primary for opt-in users
8. manual primary default
9. scheduled_node primary
10. scheduled_system primary
11. remove Legacy as execution owner
每一步必须独立通过门禁,不能因为 manual 路径稳定就直接切换 scheduled/system crontab。
18. 进入 Primary 的门禁
一个 origin 进入 primary 前必须满足:
- Shadow 完整率达到维护者接受的阈值,并公布测量窗口。
- 没有未解释的重复执行。
- terminal status 和 exit code 对账达到阈值。
- stop/exit、重复 callback、乱序 callback 和重启场景测试通过。
- 关闭 Feature Flag 后新触发立即回到 Legacy,已有执行保持 owner 不变。
- edge 基准的额外 RSS、写放大和启动时间处于预算内。
- 新 migration 从支持的 2.x 数据库升级、备份恢复和重复执行通过。
- 2.x API、CLI、Shell 和 UI 契约测试通过。
- 日志与指标不泄漏 callback token、Secret 或完整命令敏感参数。
19. 回滚策略
19.1 Shadow 回滚
将对应 origin 设置为 off 即可。已有 Shadow Run 标记为验证数据,不需要驱动 Legacy;不得删除以掩盖差异。
19.2 Primary 回滚
- 配置变更只影响新触发。
- 已由 Runtime 接受的 Run 继续由 Runtime/Executor 完成或取消。
- 禁止把 in-flight Run 重新提交给 Legacy。
- Legacy 投影保持到所有 Primary Run 终态。
- 若 Runtime 完全不可用,管理员只能执行显式、带风险提示的恢复操作;系统不得自动双跑。
- 数据库 migration 默认 forward-only;应用回滚必须能忽略新增表和可空扩展列。
20. Schema 对后续 PR 的要求
PR-1 至少需要支持:
- Run 的 legacy_cron_id、trigger_type、task_revision、status、version、时间字段和 idempotency key。
- RunAttempt 的 attempt number、status、executor identity、PID/handle、时间与 exit code。
- RunEvent 的 sequence、dedupe key、actor、attempt reference、受限 payload 和时间。
- 必要唯一索引和查询索引。
- Legacy 表新增 run_id/attempt_id 时必须可空,不破坏旧版本读取。
- 所有新增表和列通过显式、可重跑、带 checksum 的 migration 创建。
PR-1 只建 schema 和 Repository contract,不修改 CronService、Shell 或调度执行路径。
21. 被拒绝的方案
21.1 一次性替换所有运行路径
手动、system crontab、Node scheduler、once、boot、gRPC 和内部 ScheduleService 的生命周期不同,一次切换无法隔离风险,也无法快速回退。
21.2 永久双向同步
Crontab 单值状态无法无损表达并发 Run。双向同步会形成循环、迟到覆盖和不可解释的冲突。
21.3 以 PID 作为 Attempt ID
PID 会复用、仅在单机有意义、spawn 前不存在,也不能跨 Worker 或容器稳定关联。
21.4 Shadow 写失败阻断 Legacy
Shadow 的目的就是观察,不应改变生产执行结果;阻断会让验证机制成为新故障源。
21.5 Primary 失败时无条件回退 Legacy
在 spawn/派发结果不明确时回退会导致重复执行,任务可能包含通知、支付、删除等不可逆副作用。
21.6 全量历史自动回填
Legacy 数据没有完整事件顺序和稳定关联,自动构造会制造虚假的审计精度并增加 edge 升级成本。
22. 影响
正面影响
- 可以用真实 2.x 流量验证新状态机,而不立即切换生产事实源。
- 执行 owner 明确,降低迁移期间双跑风险。
- Run ID 和 Attempt ID 消除 PID/log path 猜测。
- 回滚只影响新触发,in-flight 行为可解释。
- 兼容投影为 UI/API 渐进迁移提供窗口。
负面影响
- Shadow 阶段增加数据库写入和对账复杂度。
- 一段时间内需要维护 Legacy 与 Runtime 两套读取模型。
- 旧 Shell 回调无法提供强关联,只能有限对账。
- Primary 兼容投影仍是有损的,2.x UI 看不到完整并发与终态语义。
23. 验证场景
实现必须覆盖:
- manual off 不创建 Run,2.x 行为不变。
- manual shadow 新模型写失败时 Legacy 仍只执行一次。
- 批量 manual 为每个 Cron 创建一个 Run。
- 同一 Cron 并发两次时两个 Run/Attempt 不串回调。
- 重复 running/finished callback 不重复创建 Attempt/Event。
- finished 先于 running 到达时按协议拒绝或记录 mismatch,不回退终态。
- stop 与 exit code 0 并发时 cancelled 不被覆盖。
- spawn 返回未知时不自动 Legacy fallback。
- Feature Flag 从 primary 改为 off 后,旧 Run 继续由 Runtime 完成,新触发走 Legacy。
- Crontab 在 Run 中途修改或删除时,历史 Run snapshot 不变。
- 重启后不把 Primary Run 批量重置为 idle。
- edge 上 Shadow 写入与对账保持有界。
- v2 status/log/instances API 在兼容窗口内保持契约。
- callback token 不出现在普通日志和 RunEvent payload。
24. 接受标准
- 明确接受 off/shadow/primary 三态模型。
- 明确接受按 origin 而非全局一次切换。
- 明确接受单次 execution owner 固定且禁止中途切换。
- 明确接受 Shadow fail-open、Primary 副作用前 fail-closed。
- 明确接受 Run/Attempt ID 为强关联,PID/log path 仅作兼容诊断。
- 明确接受 Primary 到 Legacy 的单向有损投影。
- 明确接受 in-flight Primary Run 不因回滚而重新提交 Legacy。