Files
qinglong/docs/adr/ADR-0002-legacy-crontab-compatibility-and-shadow-write.md
T

609 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-0002Legacy Crontab 兼容、影子写入与切换策略
- 状态:Proposed
- 日期:2026-07-18
- 决策者:QingLong Maintainers
- 关联 RFC[QL-RFC-0001](../QINGLONG_3_0_ARCHITECTURE_RFC.md)
- 前置决策:[ADR-0001](./ADR-0001-run-state-and-transaction-boundaries.md)
## 1. 决策摘要
QingLong 3.0 采用按执行来源渐进切换的三态兼容模式:
off -> shadow -> primary
- off:只执行 2.x 路径,不创建 Run。
- shadow2.x 的 Crontab、RunningInstance、Shell 回调和日志仍是用户可见事实源;系统旁路创建 Run、RunAttempt 和 RunEvent,用于验证,不参与调度与结果判定。
- primaryRun 聚合成为事实源,2.x 字段变为兼容投影;所有外部副作用由新 Runtime 发起,Legacy 路径不得再次执行同一任务。
切换按 manual、scheduled、once、boot、grpc 等 execution origin 独立进行,不允许一次性全局切换。任何单次执行在被接受时即固定 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 回调更新状态:
1. PUT /crons/run 调用 CronService.run,先把多个 Crontab 标为 queued,再异步调用 runSingle。
2. runSingle 经进程内并发限制后 spawn task 命令,并把 Crontab 更新为 running、记录 PID 和 log path。
3. system crontab 与 Node scheduler 都执行带 ID 的 Shell 命令;Node scheduler 外层的 runCron 本身不负责最终状态。
4. task.sh/share.sh 在开始和结束时调用 /open/crons/status。
5. CronService.status 在 running 回调时创建 RunningInstance,在 idle 回调时按 cron ID、PID 和状态更新实例,并更新 Crontab 的最近状态。
6. CronService.stop 按 Crontab PID 和命令查杀进程,随后把活跃实例标记为 stopped,并把 Crontab 恢复为 idle。
7. 为降低旧执行的迟到回调覆盖新执行,现有代码在 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. 目标
1. 在不改变 2.x API、CLI、Shell 和常用脚本行为的前提下验证新 Run 模型。
2. 每个执行只由一个 owner 产生外部副作用,避免双跑。
3. 允许按执行来源小步切换和快速停止扩大影响面。
4. 对重复、乱序、迟到和缺少关联 ID 的回调给出确定行为。
5. 允许 Shadow 与 Legacy 状态自动对账,并量化差异。
6. Primary 模式下继续为 2.x UI/API 提供可接受的兼容投影。
7. 保持 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 链负责执行。
- runtimeRunService、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;
}
约束:
1. 具体 origin 配置覆盖 defaultMode。
2. 3.0 Alpha 初始默认全部为 off。
3. primary 只有在 migration、状态机、Executor contract 和对账门禁通过后才可配置。
4. 配置变更必须写审计日志,包含 actor、旧值、新值和时间。
5. edge 不启动远程配置 watcher;配置刷新使用已有配置机制或显式 reload。
6. 禁止使用“数据库写失败就自动把全局模式改为 Legacy”这种隐式降级。
### 7.1 `next` Alpha Shadow 开关
当前孵化实现只开放观察型 Shadow,不通过该环境变量提供 primary:
QL3_SHADOW_ORIGINS=manual,scheduled_node
- 未设置或设置为空时全部为 off。
- 当前只接受 `manual``scheduled_node`;未知 origin 被忽略并记录有界配置告警,不扩大到通用调度入口。
- 配置在进程内首次使用时读取;edge 不启动 watcher,变更后需要通过既有进程重启或未来的显式 reload 生效。
- 兼容观察器在实际 HTTP/gRPC worker 中按需加载;关闭时不构造 Shadow 事实或任务摘要、不增加 ChildProcess 监听器、不初始化 Repository、不创建后台任务,也不引入额外数据库写入。
- `manual``scheduled_node` 都只监听 Legacy 已创建的同一个 ChildProcess。Shadow 代码不得调用 Executor 或第二次 spawn。
- 任意初始化、接受或后续写入失败都退化为 no-op,只记录不含命令、环境变量和 Secret 的稳定错误类型与有界计数。
- `boot` 虽复用 `runSingle`,仍携带独立 origin,当前不在允许列表中,不能被误记为 manual。
该环境变量是 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:<id>。
- 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 尝试:
1. 创建 Run,初始状态为 created。
2. 追加 run.created。
3. 当 Legacy 进入队列时转换为 queued 并追加 run.queued。
4. 创建 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 runningRun running |
| Shell idleexit code 0 | Attempt succeededRun succeeded |
| Shell idleexit code非 0 | Attempt failedRun 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` 增加两级、失败开放的兼容关联:
1. 同一 worker 内使用最多 256 条 active execution 的内存注册表,保持 stop/callback 与 ChildProcess exit 的入队顺序;终态、启动失败和取消后立即移除,不启动清理定时器。
2. callback 或 stop 落到其他 worker 时,按 `legacy_cron_id + enabled origins + legacy owner + active status` 查询持久化候选;单次最多读取 64 个,超限只处理有界集合并记录 truncated,不能无界扫描。
3. 单实例匹配依次使用 opaque log artifact、PID 和唯一候选兜底。日志与 PID 各自唯一但指向不同 Run 时视为冲突;零候选、多候选和冲突都只记录 unmatched/ambiguous,不猜测更新。
4. log path 只在进程内转换为不超过 36 字符的稳定摘要;原始路径、命令和环境变量不进入关联告警。
5. stop all 取消该 Cron 的全部有界 active Shadow Runstop instance 只在强字段或唯一候选能够确定一个 Run 时取消。
6. 取消事实在 Legacy kill 前投递;同 worker 的后续 exit 排在取消之后。跨 worker 使用持久化定位器尽力关联,任何查询或写入失败都不能阻断 kill 或改变 2.x API 响应。
7. 乱序 finished 可以从 queued/claimed 补齐 dispatching、starting、running 和终态;重复终态 callback、取消后的迟到成功 callback 不覆盖终态,也不追加重复完成事件。
这仍不是完整 Reconciler:当前没有启动后批量扫描、缺失事实修复、公开指标采集器或 Shadow/Legacy 差异报表。上述能力必须在 Primary 门禁前补齐,但 edge 默认不得因此增加常驻 watcher 或无界内存队列。
### 9.3 Shadow 写失败
Shadow 写失败时:
1. Legacy 执行继续。
2. 写结构化错误日志和有界计数指标。
3. 若已经存在 Run,尽力追加 compat.shadow_write_failed;数据库不可用时不做无界内存缓存。
4. 对账器可以在数据库恢复后标记缺失或不完整记录,但不得伪造未知的精确时间和事件顺序。
5. Shadow 完整率低于门禁时禁止进入 primary。
## 10. Primary 执行规则
### 10.1 触发接受
Runtime owner 必须先完成以下持久化事务,再进入队列或 spawn:
1. 创建 Run 和 run.created。
2. 将 Run 转换为 queued 并追加 run.queued,或以等价的单事务命令完成。
3. 写入唯一 idempotency key 或 trigger delivery key。
4. 确认事务提交成功。
事务失败时不得 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>:<sequence>:<event>。重复回调返回已提交结果;小于已提交序列的迟到回调只记录受限诊断,不修改终态。
### 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 模式
1. Cancel command 在同一事务读取 Run/Attempt,使用 Run version/CAS 写入首次 `cancel_requested_at_ms`、受限 reason 和 `run.cancel_requested`;此时不提前把实际进程标记为已退出。
2. 事务提交成功后才允许 Executor 执行 cancel/kill;提交失败不得发送 signal。
3. 重复取消不追加第二个请求事件,但可以幂等重试 stop;stop 失败保留 durable request,由 Reconciler 继续检查。
4. Attempt 终态先提交时,取消返回 already-terminal 且不发送 signal;取消请求先提交时,迟到 exit code 0 或其他非 cancel 完成结果收敛为 cancelled。
5. 实际完成后 Attempt/Run 转为 cancelled,并由同事务 Projection 更新 RunningInstancekill 结果和后续核验事件不得包含原始命令、环境或 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. once/boot/grpc shadow
6. manual primary for opt-in users
7. manual primary default
8. scheduled_node primary
9. scheduled_system primary
10. remove Legacy as execution owner
每一步必须独立通过门禁,不能因为 manual 路径稳定就直接切换 scheduled/system crontab。
## 18. 进入 Primary 的门禁
一个 origin 进入 primary 前必须满足:
1. Shadow 完整率达到维护者接受的阈值,并公布测量窗口。
2. 没有未解释的重复执行。
3. terminal status 和 exit code 对账达到阈值。
4. stop/exit、重复 callback、乱序 callback 和重启场景测试通过。
5. 关闭 Feature Flag 后新触发立即回到 Legacy,已有执行保持 owner 不变。
6. edge 基准的额外 RSS、写放大和启动时间处于预算内。
7. 新 migration 从支持的 2.x 数据库升级、备份恢复和重复执行通过。
8. 2.x API、CLI、Shell 和 UI 契约测试通过。
9. 日志与指标不泄漏 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. 验证场景
实现必须覆盖:
1. manual off 不创建 Run2.x 行为不变。
2. manual shadow 新模型写失败时 Legacy 仍只执行一次。
3. 批量 manual 为每个 Cron 创建一个 Run。
4. 同一 Cron 并发两次时两个 Run/Attempt 不串回调。
5. 重复 running/finished callback 不重复创建 Attempt/Event。
6. finished 先于 running 到达时按协议拒绝或记录 mismatch,不回退终态。
7. stop 与 exit code 0 并发时 cancelled 不被覆盖。
8. spawn 返回未知时不自动 Legacy fallback。
9. Feature Flag 从 primary 改为 off 后,旧 Run 继续由 Runtime 完成,新触发走 Legacy。
10. Crontab 在 Run 中途修改或删除时,历史 Run snapshot 不变。
11. 重启后不把 Primary Run 批量重置为 idle。
12. edge 上 Shadow 写入与对账保持有界。
13. v2 status/log/instances API 在兼容窗口内保持契约。
14. 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。