Files
qinglong/docs/adr/ADR-0023-immutable-task-execution-revision-storage.md
T

98 lines
6.0 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-0023:不可变 Task Execution Revision 存储
- 状态:Proposed
- 日期:2026-07-18
- 关联:QL-RFC-0001、ADR-0004、ADR-0014、ADR-0022、ADR-0024
## 上下文
ADR-0022 已规定 Dispatcher 必须用 Run 固定的 Project/Task/revision 三元组重建执行模板,禁止回退当前 Task。只有内存接口还不够:控制面重启、Task 后续编辑或自动重试创建 Attempt N+1 时,都需要一个可校验、精确读取且不会被覆盖的持久化事实。
部署范围同时包含资源受限路由设备与 cluster-control。edge 需要低常驻开销、单行写入和 SQLite 本地恢复;cluster-control 需要共享 PostgreSQL、多副本一致性和独立保留策略。不能让两者通过共享 SQLite 或各自解释可变 Task 行获得看似相同、实际漂移的语义。
## 决策
### 1. revision 身份是复合主键
`TaskExecutionRevisions` 使用 `(project_id, task_id, task_revision)` 作为主键,并保存:
- `executor_type`
- canonical `execution_template`
- opaque `context_ref`
- `content_digest`
- 首次成功写入的 `created_at_ms`
不建立到当前可变 Task 行的级联外键。删除或修改当前 Task 不能使历史 Run 的 revision 消失或改变。表不保存 Secret 明文、环境值、Run/Attempt identity、Worker lease、callback token、PID/handle 或日志写句柄。
### 2. 写入是 append-only
Repository 只暴露 `insert()` 和精确 `resolve()`,不提供 update/upsert/delete
- 新身份写入返回 `inserted`
- 同身份、同规范化内容的并发或崩溃重放返回 `idempotent`,保留第一次时间;
- 同身份、不同内容返回稳定的 immutable conflict,禁止 last-write-wins。
未来 retention 删除必须先证明没有 Run、Attempt、审计保留或恢复窗口仍引用该 revision;该能力不属于当前切片,不能用通用 CRUD 绕过。
### 3. 摘要基于已知字段的 canonical 表示
写入前复用 `ExecutionSpec` 边界校验,剥离未知字段,深拷贝 command/resource policy,并按固定字段顺序生成 canonical JSON。SHA-256 覆盖 Project/Task/revision、executor、执行模板和 `contextRef`
读取时重新解析、规范化并核对 canonical bytes 与摘要。非法 JSON、非 canonical 模板、非法 executor、越界字段、时间损坏或 digest 不一致均 fail closed,不把可疑记录交给 context materializer 或 Executor。返回值及嵌套命令/资源结构被冻结,调用方不能修改共享事实。
摘要用于损坏检测和幂等比较,不代替数据库访问控制、备份校验或发布者签名。
### 4. edge 与 cluster 共享 contract,不共享 adapter
当前 `next``0012-task-execution-revisions` migration 和 Sequelize adapter 是 SQLite edge/standalone 孵化实现:
- 每个 revision 一次单行写入,没有常驻 timer、后台索引器或外部服务;
- 读取只有复合主键点查;
- 为未来按 Project 和创建时间执行有引用证明的保留扫描提供索引;
- adapter 在非 SQLite dialect 构造时直接拒绝。
cluster-control 必须实现 PostgreSQL repository,并用共享数据库事务、冲突语义和独立 retention worker;不能挂载共享 `database.sqlite`,也不能假设单进程锁。两种 adapter 必须通过同一 contract suite,并产生同一 canonical digest。
### 5. 当前仍不接入生产 Dispatcher
ADR-0024 已进一步建立 content-addressed context recipe、Secret provider port 和 attempt-scoped 本地 Artifact/output allocator。动态 `ExecutionContext` 仍需要生产 SecretStore、运行中日志 quota/retention 和 callback capability adapter;可信 retry policy provider、Dispatcher lifecycle、管理/API 写入入口、PostgreSQL/object Artifact adapter、指标告警和备份门禁也未完成。
因此默认 manual Primary 仍不构造该 repositorylocal Dispatcher 仍保持 production unreachable。不能因为 `0012` 已随 migration chain 建表就默认开启自动重试或切走 Legacy 调度。
## 影响
正面影响:
- Task 编辑或删除不会改变历史 Run 与 Attempt N+1 的执行模板;
- 同 revision 的并发发布可以安全收敛,内容碰撞显式失败;
- SQLite edge 只增加一次有界单行写入和主键点查;
- cluster 可以替换 adapter,而不改变 materializer 和 Dispatcher 语义。
代价与风险:
- revision 表随 Task 修改增长,需要引用感知 retention、备份和容量指标;
- SHA-256 不能证明内容发布者身份,Package/插件签名仍需独立协议;
- `contextRef` 也属于摘要覆盖内容,引用方案迁移必须创建新 revision,不能原地改写;
- 当前 Sequelize adapter 是孵化桥接层,最终 node:sqlite/PostgreSQL 实现仍需 contract 与并发压力验证。
## 未选择的方案
1. **只在 Run 保存一段任意 JSON**:缺少共享规范化、精确复用和保留边界,拒绝。
2. **相同 key 使用 upsert 覆盖**:历史执行事实会被改写,拒绝。
3. **只比较调用方提供的 revision 字符串**:无法检测同名不同内容和存储损坏,拒绝。
4. **把 Secret 环境写入 execution template**:扩大静态泄漏与备份暴露面,拒绝。
5. **cluster-control 复用共享 SQLite**:没有多副本事务与运维边界,拒绝。
6. **读取失败时回退当前 Task/latest**:不可审计且可能执行错误内容,拒绝。
## 验证要求
- migration chain 与 schema ownership manifest 覆盖新表、复合主键、索引和约束;
- 精确三元组命中,错误或不存在的 revision 返回 unavailable
- 输入在写入后修改不影响存储,读取结果嵌套冻结;
- 多个并发同内容写入恰好一次 inserted,其余 idempotent
- 同 key 异内容稳定冲突,不覆盖首次记录;
- 非 canonical template、非法 JSON、摘要或字段损坏 fail closed
- SQLite adapter 拒绝 cluster dialect
- Node 22、Node 24 构建与全量回归通过;
- 后续 PostgreSQL/node:sqlite adapter 复用同一 contract 和 digest vectors。