Files
qinglong/docs/adr/ADR-0007-local-process-completion-receipt-and-log-survival.md
T

236 lines
18 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-0007LocalProcess completion receipt 与日志存活
- 状态:Proposed
- 日期:2026-07-18
- 决策范围:本地进程跨控制面重启时的日志连续性、完成事实、timeout 恢复和有界清理
- 关联:QL-RFC-0001、ADR-0001、ADR-0003、ADR-0005
## 1. 上下文
当前 `LocalProcessExecutor` 通过父进程持有的 stdout/stderr pipe 写日志,并通过 `ChildProcess.close` 解析退出码后完成内存 Promise。正常运行时这条路径简单且能保持实时输出,但 HTTP worker 重启后存在两个不可恢复事实:
1. 内存 completion Promise、timeout timer 和 active-handle Map 消失。
2. 仍在运行的子进程虽然可以通过 durable PID identity 被证明存活,但旧 pipe 的读取者已经退出;进程随后退出时,新 worker 无法获得原始 exit code 或 signal。
仅轮询 PID 只能判断“此刻存在或不存在”,不能证明退出原因。读取日志尾部、文件 mtime 或脚本约定文本也不能作为可信完成事实。直接把这类 Attempt 标记 succeeded 会制造假成功;全部立刻标记 lost 又会丢失本可恢复的完成和日志。
协议必须同时满足:
- edge:SQLite、本地文件系统、低内存、低频扫描,无常驻重型 sidecar 和目录 watcher。
- standalone:控制面重启时本地任务可以继续运行并留下可恢复结果。
- cluster worker:完成事实可以在 Worker 断连后重放,但必须受 worker lease/fencing 约束。
## 2. 决策
### 2.1 事实所有权
- Run、RunAttempt 和 RunEvent 仍是最终业务事实源。
- 日志 Artifact 是追加写输出事实,不参与判断成功或失败。
- CompletionReceipt 是 Executor 在数据库终态提交前的本地恢复 journal,不是第二套 Run 状态表。
- PID、process group、文件存在和内存对象都只是证据,不能单独生成 succeeded。
receipt 采用至少一次发现,CompletionService 通过 Run version、Attempt 状态和 callback sequence 保证同一终态至多一次生效。
### 2.2 Spawn 前持久化
在产生本地进程副作用前,Runtime 必须为 Attempt 准备并持久化:
```text
run_id
attempt_id
executor_type = local_process
log_artifact_id
callback_token_hash
callback_sequence
deadline_at_ms nullable
```
明文 callback token 只交给本次执行包装层,数据库只存 hash。token 用于防止误关联和跨 Attempt 重放,不宣称能隔离与 QingLong 同一 OS 用户运行的恶意脚本;强安全隔离必须使用 Docker、Kubernetes 或 Remote Worker。
若持久化失败,不得 spawn。spawn 成功后仍按 ADR-0003 保存 durable handlehandle 持久化失败时执行 stop + lost 补偿,不把 receipt 当成绕过该边界的理由。
### 2.3 日志直接落盘
QingLong 在 spawn 前创建唯一日志 Artifact,规范化路径并限制在配置根目录内,文件权限默认 `0600`。stdout/stderr 直接继承该 Artifact 的追加写文件描述符,控制面不再是唯一 pipe reader。
结果:
- HTTP worker 重启不会关闭仍由子进程持有的日志 fd。
- 实时日志改为按需 tail Artifact;无订阅者时不创建 reader、timer 或额外缓冲。
- 慢 WebSocket/SSE 客户端只丢失实时推送或从 offset 续读,不能反压用户进程。
- 首版可以把 stdout/stderr 合并到同一 legacy-compatible 日志;若未来分流,必须额外记录稳定 stream/offset,不能用两个异步 pipe 猜测全局顺序。
日志文件不能承载 callback token、命令快照或环境。日志轮转和删除遵循 Artifact retention,不能因为 receipt 已消费就立即删除。
### 2.4 CompletionReceipt
执行包装层等待用户命令结束并发布固定 schema 的 receipt。首版上限 4 KiB
```json
{
"schemaVersion": 1,
"runId": "uuidv7",
"attemptId": "uuidv7",
"callbackSequence": 1,
"token": "opaque-random-value",
"startedAtMs": 0,
"finishedAtMs": 0,
"exitCode": 0
}
```
约束:
- 只接受固定字段;未知字段、重复 JSON key、非整数时间、越界 exit code、错误 ID 或超限文件一律 quarantine,不尝试宽松修复。
- 不记录命令、参数、cwd、环境、Secret、stdout/stderr、任意用户 error text 或堆栈。
- `finishedAtMs >= startedAtMs`;数据库仍将时间钳制到 Run/Attempt 已提交时间之后。
- wrapper 被 signal 终止、磁盘写失败或文件系统不可用时可以没有 receipt;Reconciler 最终将其归类为 lost,而不是伪造 exit code。
发布步骤:
1. 在同一目录创建不可覆盖的临时文件。
2. 写入完整、有界的 canonical payload,并在实现支持时 flush 文件。
3. 通过 rename-no-replace、同目录 hard-link publication 或等价原语发布到最终 receipt 路径;目标已存在时不得覆盖。普通会覆盖目标的 rename 不满足该约束。
4. 在实现支持且资源预算允许时同步父目录元数据。
原子 publication 只保证读者看到“缺失”或完整新状态,不承诺抵抗所有断电和损坏场景。
### 2.5 CompletionService
正常 `ChildProcess.close` 快速路径和 receipt 恢复路径必须调用同一个 CompletionService。服务在单一数据库事务内:
1. 校验 Run/Attempt 关系、runtime owner、executor type、callback token hash 和严格递增的 callback sequence。
2. 读取当前 Run/Attempt;已经终态时返回 already-terminal,不追加第二个终态事件。
3. 若 Run 已有 cancel request,则按 ADR-0001 收敛为 cancelledtimeout 也是先持久化的取消意图,不能仅由进程 exit code 推断。
4. CAS Run version/event sequence,更新 Attempt 终态并追加低敏 RunEvent。
5. 提交成功后才允许标记 receipt consumed 并进入清理。
receipt 已发布、事务未提交时重启,下一轮可以重放。事务已提交、receipt 尚未删除时重放,返回 already-terminal 后安全清理。禁止先删 receipt 再提交数据库。
`next` 的实现已将原有 Orchestrator 实时 completion 切换到该服务。服务在一个 Repository transaction 中提交 Attempt 状态/sequence、Run 状态和两个有序 Eventtoken 使用常量时间 SHA-256 比较,错误 token、错误 sequence、非 runtime owner 或不匹配 executor type 均在写入前拒绝。timeout 取消意图已提交时,迟到的成功 receipt 收敛为 `timed_out`。receipt consumer 只在 `applied` 或一致的 `already_terminal` 后清理文件;事务失败时保留文件,清理失败时允许下次无重复 Event 地重放。对于数据库已知 Attempt,确定性的 codec/schema、token、sequence、owner 或状态错误会先持久化 `quarantined`,再将原文件以确定性 hard-link/no-replace + unlink 移入 `0700` 私有分片 quarantine;原内容不写日志,启动恢复与周期扫描只上报计数和有界相对引用。数据库/CAS/普通文件系统错误仍保留原 receipt 重试。
### 2.6 Startup 与周期 Reconciler
恢复顺序固定为:
1. 从数据库按稳定 cursor 分页读取 runtime-owned active Attempt;不得遍历整个 receipt 根目录作为主索引。
2. 若对应 receipt 存在,先校验并交给 CompletionService。
3. 无 receipt 时检查 durable process identity。
4. identity 仍运行:保持 running,并由周期 Supervisor 后续复查。
5. identity 已退出:等待短且有上限的 receipt publish grace;仍缺失则标记 `RECOVERY_PROCESS_EXITED_WITHOUT_RECEIPT`/lost。
6. identity 不支持、歧义或不匹配:fail closed,按稳定错误分类 lost 或 blocked,绝不按裸 PID 操作。
Supervisor 复用 ADR-0005 的生命周期约束:显式 start/stop、timer unref、每轮完成后再调度、无重叠、分页和页数硬上限、shutdown 有界 drain。edge 默认低频串行检查,不启动文件 watcher。
### 2.7 Timeout 与取消
内存 timeout timer 只是快速路径。`deadline_at_ms` 必须在 spawn 前持久化:
- Reconciler 发现 deadline 已过期且 Run 未终态时,先事务提交 `run.cancel_requested(reason=timeout)`
- durable cancellation dispatcher 取得 Attempt-bound lease 后才允许 TERM/KILL。
- completion receipt 先到时由事务竞争决定:deadline 前已发布且可验证的完成可以正常收敛;已接受 timeout 意图后到达的普通完成收敛为 timed_out/cancelled 的具体映射由 ADR-0001 固定,不能由扫描顺序决定。
用户取消、策略取消和 shutdown 使用相同原则。receipt 不携带“我是被取消的”自我声明;权威取消原因来自已提交 Run 意图。
### 2.8 edge 与 cluster 映射
edge/standalone
- 每个 active Attempt 只有一个日志 Artifact 和最多一个最终 receipt。
- receipt 目录按 Attempt ID 前缀分片,避免单目录无界增长。
- 不为每个任务启动 Node runtime sidecarlauncher 的具体实现必须经过 RSS、信号转发、架构和供应链评审。
- 在线清理器只处理 Journal 已索引的 Attempt,不遍历目录。非 Journal 文件交给独立维护任务,默认只读且不能递归盲删。
cluster worker
- receipt/journal 保存在执行 Worker,控制面数据库不引用其他节点不可访问的本地绝对路径。
- Worker 向控制面提交 completion 时携带 worker lease/fencing token;控制面确认前 Worker 不删除 journal。
- 断线后可以重放;stale worker 的 completion 被 fencing 拒绝,不能覆盖新 Attempt。
- 日志上传到共享 ArtifactStore 时记录可续传 offset/checksum;本地日志在远端确认前不得作为唯一副本删除。
### 2.9 非 Journal 孤儿文件维护
目录 watcher、启动时全量 `readdir` 和无上限递归清理均不进入 Runtime 热路径。`next` 提供独立 `audit:receipts:ql3` 命令,契约如下:
1. 数据库使用 Node 24 defensive read-only 连接,只批量读取 `RunAttempts``CompletionReceiptJournals`,不启动 Sequelize、HTTP worker 或 Runtime lifecycle。
2. 默认从显式 shard cursor 审计 8 个十六进制分片、每分片最多 32 条;硬上限为 32 个分片和每分片 64 条。扫描使用 `opendir` 增量迭代,不读取文件内容。
3. Journal 已登记或 Attempt 仍 active 的 receipt 永不由该命令移动。未登记的 terminal/unknown receipt、临时文件和未知普通文件必须先超过 minimum age。
4. 默认模式只输出分类和 `nextShard`。只有显式 `--quarantine` 才允许将 eligible 普通文件通过同文件系统 hard-link + unlink 移到私有 `0700` `.orphan-quarantine`;不提供直接删除。
5. symlink、目录及其他非普通文件只报告 unsafe;分片目录和隔离目录必须通过 canonical root 复验,symlink escape 直接失败。分片出现第 `limit + 1` 个条目时标记 overflow,该分片本轮全部禁止移动,避免局部枚举导致误判和饥饿。
6. edge 可由低频 cron 使用 `nextShard` 轮转;standalone 或 cluster Worker 可作为节点维护 Job 执行。cluster-control 不扫描 Worker 本地路径。
该工具不把“文件不在 Journal”直接等同于可删除:active Attempt 是升级兼容保护,minimum age 是 publication/清理竞态保护,quarantine 是可恢复保护。隔离区的最终保留、人工确认和删除策略在 Artifact retention 运维面统一处理。
## 3. 不采用的方案
### 3.1 仅依赖 ChildProcess.close
父进程重启后监听器和 Promise 消失,无法恢复,拒绝。
### 3.2 仅轮询 PID 或 `/proc`
只能证明进程身份和当前存活状态,不能恢复退出码、signal 或完成时间,拒绝作为成功事实。
### 3.3 从日志尾部解析退出码
日志由用户命令控制,可能伪造、截断或缺失;日志内容也可能被 retention 修改,拒绝。
### 3.4 每个任务启动完整 Node sidecar
实现直接但会显著放大 edge 并发任务的 RSS 和启动成本,首版拒绝。若未来基准证明可接受,仍需满足同一 receipt 契约。
### 3.5 控制面监听整个 receipt 目录
文件 watcher 在不同文件系统和容器挂载上的语义不一致,也会让 edge 为禁用或空闲能力支付常驻成本。允许作为未来 best-effort 唤醒优化,但不能代替数据库索引和周期扫描。
### 3.6 receipt 直接替代数据库 Run 状态
本地文件没有 Run version、跨节点 fencing、权限和查询事务,拒绝。它只是可重复消费的 Executor 事实。
## 4. 影响
正面影响:
- 控制面重启不再必然中断本地日志,正常退出码可以在 receipt 存在时恢复。
- 正常完成和恢复完成共享事务入口,减少双写和竞态分叉。
- edge 不需要外部数据库、队列、对象存储或常驻重型 supervisor。
- cluster Worker 获得同构的 journal + ack 重放模型。
代价:
- 每个 Attempt 增加一个小 receipt 文件和若干文件系统元数据操作。
- 需要持久化 deadline,并调整 LocalProcess 的 stdio 与实时日志实现。
- launcher 的信号转发、shell/argv 语义、多架构发布和异常退出需要单独验证。
- 恶意本地脚本与控制面使用同一 OS 身份时,callback token 不是强隔离边界。
## 5. 当前孵化边界
`next` 已实现 durable process identity、启动时有界扫描、running 验证、无证明时 lost、cancel lease/fencing、manifest-gated manual Primary boot、严格 CompletionReceipt codec/原子文件 Store,以及 `0006-run-attempt-deadline``0007-completion-receipt-journal` 两个增量 schema。Primary 在 spawn 前登记 Journal,登记失败不得 spawn;启动恢复为升级前 active Attempt 幂等补登记。周期 completion scanner 已改由 Journal 驱动,所以 Run 终态后残留 receipt 仍可返回 `already_terminal` 并清理。Journal 只保存 pending/quarantined、本地相对引用和 retention 游标,不改变 Run/Attempt 聚合。终态 missing 记录按 edge 2 分钟、standalone 1 分钟清理;quarantine 按 edge 5 分钟、standalone 1 小时精确删除。timeout lifecycle 已接入 manual Primary canary:显式 start/stop、timer unref、无重叠、有界 drain、每轮固定观察时间,并按 edge30 秒、2×8)与 standalone5 秒、4×32)选择资源上限;cluster-control/worker 拒绝误装该 SQLite 本机栈。统一 CompletionService 已接管实时 Executor completionspawn 前写入 token hashreceipt consumer 复用同一事务入口并通过两个 crash-window 测试。manual Primary 使用受限 POSIX launcher 和同一 `0600` append fd;正常 live cleanup、重启 replay、隔离和 purge 都会收敛 Journal。Startup Reconciler 在 identity 前和非 running 结论前消费 receiptidentity 明确 exited 且 PID 未失配时按 edge 50 ms、standalone 100 ms 再等待一次。所有周期扫描都有页大小/页数上限、跨周期 resume cursor、timer unref、无重叠和有界 stop。普通 LocalProcess pipe 与 Legacy owner 路径保持不变。
因此当前 manual Primary canary 已能在 HTTP worker 强制退出后继续写同一日志、生成可验证 receipt,并由新 worker 在启动或周期扫描中消费;确定性非法 receipt、终态残留和 retention 均有数据库索引且不依赖目录扫描。非 Journal 文件已有独立的只读优先、有界审计和显式隔离策略;代码门禁也验证了 ENOSPC 不暴露半成品,以及 receipt 存储失败不篡改用户任务退出码。固定 edge/多架构实机资源门禁和真实磁盘压力演练未关闭前,仍不得把 Primary 扩大到 boot、schedule、subscription 或默认 manual 流量。
建议实施切片:
1. 已增加不可达的 CompletionReceipt port、严格 codec、路径和原子发布 contract tests。
2. 已增加 `deadline_at_ms` 兼容 migration、Repository 映射、Primary spawn 写入与 timeout source,不修改既有 baseline checksum。
3. 已在 manifest-gated manual canary 中切换 direct-file stdio 与受限 POSIX launcher,保持 Legacy 默认路径和普通 pipe Executor 不变。
4. 已合并正常 completion 与 receipt completion 的事务服务,并加入两个 crash-window 测试。
5. timeout、Journal 驱动的 completion Supervisor、profile 化 publish grace、确定性隔离、retention 和非 Journal 运维 CLI 已接入;继续完成真实 worker crash、HTTP restart、真实磁盘压力和 edge 资源演练后再新增 rollout gate。
## 6. 验证门禁
1. HTTP worker 在任务运行中被强制退出后,任务继续向同一日志 Artifact 追加输出。
2. receipt publish 后、终态事务前崩溃,重启只生成一个终态 Event。
3. 终态事务后、receipt 清理前崩溃,重放返回 already-terminal 且安全清理。
4. 临时文件、半写、超限、未知字段、错误 token/sequence/Attempt 全部 fail closed,不改变 Run。
5. 进程已退出且 receipt 缺失时只能 lost,不能从日志或 PID 猜测 succeeded。
6. deadline 跨重启仍触发先持久化、后 signal 的 timeout 流程。
7. 取消与 completion 两种提交顺序都遵循 ADR-0001,迟到 receipt 不能覆盖已接受取消。
8. 无日志订阅者时不创建 tailer;慢订阅者不阻塞用户进程。
9. 单轮扫描、文件大小、目录分片、quarantine 和清理数量均有硬上限。
10. edge 基准验证 launcher、direct-file log 和 Supervisor 的 RSS、启动时延、磁盘写放大。
11. ARM64/AMD64 以及声明支持的 libc/架构通过 signal、exit code、断电近似和文件系统语义测试。
12. receipt、Event、审计日志均不包含命令、环境、Secret 或用户输出。
13. 非 Journal 审计默认只读,单次 shard/entry 数有硬上限;overflow、symlink、目录和 active Attempt 全部 fail closed。
14. ENOSPC 不产生可见最终 receiptreceipt publication 失败不改变用户进程原始退出码。