12 KiB
ADR-0026:本地 Artifact 硬配额、磁盘水位与可恢复 Retention
- 状态:Proposed
- 日期:2026-07-18
- 关联:QL-RFC-0001、ADR-0007、ADR-0021、ADR-0024、ADR-0025
2026-08-12:Local Run Attempt 日志 retention、durable tombstone、压力档位与 lifecycle 接线已由 ADR-0378 接受;本 ADR 仍保持 Proposed,仅表示其余通用 Artifact quota/跨 Profile 扩展尚未整体关闭。
上下文
ADR-0024 已建立 Attempt-scoped opaque Artifact 和 direct-file durable output,但未限制单次运行能够写入的字节数,也没有终态清理证据。对小型路由设备,这意味着一个失控脚本可以填满系统盘;只按 timer 删除目录又可能误删仍在运行、等待 completion receipt 或无法证明进程退出的日志。standalone/cluster 虽然容量更高,同样需要可审计的配额和生命周期语义。
durable LocalProcess 不能只依赖 Node ExecutionOutputSink:stdout/stderr 使用继承的文件描述符,控制面崩溃后 launcher 和用户进程仍继续写。Linux/POSIX 没有通用的 per-file-descriptor byte quota;RLIMIT_FSIZE 会错误影响任务自己打开的其他文件,并可能用 SIGXFSZ 改变业务退出结果。
决策
1. 每个 Attempt 必须显式声明容量策略
LocalArtifactCapacityPolicy 包含:
maximumAttemptBytes:单 Attempt 日志硬上限,允许范围 64 KiB~1 GiB;minimumFreeBytes:不得被新 Attempt 消耗的磁盘保留水位。
默认 profile 建议为:
| Profile | 单 Attempt 上限 | 最小空闲保留 |
|---|---|---|
| edge | 4 MiB | 32 MiB |
| standalone | 64 MiB | 256 MiB |
3.0 本地 allocator 不再提供隐式无限配置。创建 Artifact 前先读取文件系统 bavail × bsize;只有 available bytes 至少覆盖 minimumFreeBytes + maximumAttemptBytes 才允许打开文件。检查发生在 Artifact 文件副作用前,失败返回稳定 capacity unavailable。它是 admission reserve,不是对同文件系统其他进程的空间租约,所以运行中写入仍必须处理 ENOSPC。
2. 普通 pipe 与 durable direct-file 共用同一硬上限
普通 pipe sink 串行维护剩余额度,越界 chunk 只写入尚可接受的前缀,绝不多写一个字节,然后返回 quota exceeded;Executor 继续排空 child stream 并产生 bounded output-sink diagnostic。
durable 模式把 maximumBytes 作为非枚举 adapter capability 交给 launcher。Node 仍以 O_APPEND|O_NOFOLLOW 安全打开日志并传递 FD;launcher 在同一私有 shard 创建确定性的 0600 FIFO,启动一个有界 drainer:
head -c remaining只把剩余额度写到继承的安全 FD;- 同一 reader 用
wc -c排空剩余字节,只根据计数判断是否真正达到 quota,不保存用户内容; - stream 关闭后始终发布固定格式、不可覆盖的 truncation fact,记录 Run/Attempt/Artifact、实际 quota、
quotaReached=true|false和 observation time;fact 缺失只能解释为unknown,禁止推断为未截断; - 用户进程不会因 reader 退出收到 SIGPIPE,退出码和 completion receipt 语义不因日志截断改变;
- launcher 等待 child 与 drainer,再删除 FIFO。控制面进程提前退出不会中断 launcher、drainer 或用户进程。
fact 不包含 callback token、命令、环境、Secret 或用户输出,也不能授权 completion、retention 或调度状态变化。launcher 通过同目录临时文件和 hard-link/no-replace 发布;本地 reader 只读取 canonical 私有 shard 中的普通非 symlink 文件。事实文件可以被日志 API 解释为 true|false|unknown,但不能作为进程退出证明。
这增加至多一个轻量 POSIX drainer,不增加每任务 Node sidecar、watcher 或 timer。Alpine 镜像已有 coreutils;Debian 必须在镜像契约中持续验证 head -c、wc -c 与 mkfifo。遗留 manual Primary 未提供 maximumBytes + logArtifactId 时保持旧 direct-file 路径,不被本切片静默切换。
3. Retention 只相信数据库终态和 receipt settlement
SQLite candidate source 只返回同时满足以下条件的记录:
- Run
execution_owner=runtime,Run 与 Attempt 均为succeeded|failed|cancelled|timed_out; - Executor 为
local_process,log_artifact_id是 canonicallocal-*; - Attempt
finished_at_ms已超过当前 retention cutoff; - 不存在 CompletionReceiptJournal,说明 completion receipt 已消费/隔离处理完成;
- 不存在该 Attempt 的 retention tombstone。
lost 明确排除:lost 只表示控制面无法证明 ownership,不证明底层进程已经退出。没有 durable execution settlement 证据时删除 lost Artifact 可能 unlink 一个仍被孤儿进程写入的 inode。以后若引入 execution_settled 事实,应通过独立 ADR 扩展,而不能从 error code 猜测。
候选按 (finished_at_ms, attempt_id) keyset 排序,默认 16、最多 64;每次 sweep 默认最多删除 8、最多 64。单页 service 无全目录扫描、递归分页、timer 或每 Task 状态;cadence 由后述显式 lifecycle 管理。
4. 低水位只能缩短到显式 pressure retention
Retention service 每个 sweep 只采样一次 clock 和 capacity:
- 空闲空间不低于水位时使用
normalRetentionMs; - 低于水位时使用显式配置的
pressureRetentionMs,且必须在 1 分钟到 normal retention 之间; - 即使磁盘承压,也只删除满足 terminal/receipt 条件的 Artifact,不删除 active、lost 或未知文件。
压力模式不是“扫目录删最老文件”。持续低水位必须产出指标/告警,并允许调度 admission 暂停低优先级任务或拒绝新任务。
5. Lifecycle 只运行单页并持久化 CAS cursor
0016-local-artifact-maintenance-cursor 增加单行 LocalArtifactMaintenanceCursors checkpoint,保存 (finished_at_ms,attempt_id)、version 和更新时间。lifecycle 每次 tick 只读取一个 checkpoint、执行一个 sweep page,再按 expected version CAS 推进或清空:
page_complete|deletion_budget_exhausted必须持久化 resume cursor;到达尾部后清空 cursor,下个 tick 从头重试此前失败候选;- 当前 cursor 和目标 cursor 都为空时不写数据库,避免空闲路由器周期性改写 SQLite/WAL;
- CAS 失权只输出
fenced摘要,不假装持有 cadence ownership;文件删除和 tombstone 本身仍按幂等协议收敛; - 一个
unreftimer、无重叠 cycle、显式 start/stop、stop wait 有上限;observer 只收到无 Attempt/Artifact ID 的聚合值,statfs bigint 转成十进制字符串以便 JSON exporter 安全处理。
默认资源建议为:
| Profile | cadence | page | 每周期最多删除 | normal retention | pressure retention |
|---|---|---|---|---|---|
| edge | 5 分钟 | 8 | 4 | 7 天 | 1 天 |
| standalone | 1 分钟 | 32 | 16 | 30 天 | 7 天 |
这些是代码级安全默认值,不替代 Project policy;当前 lifecycle 仍未接入生产 startup。
6. 删除后写 tombstone,崩溃可收敛
0015-local-artifact-retention 增加 LocalArtifactRetentions,每 Attempt 一条不可覆盖记录,保存 Artifact ID、finished/eligible/recorded time、deleted|already_absent 和 reclaimed bytes;同时为 RunAttempt terminal candidate 增加 (status, finished_at_ms, id) 索引。
操作顺序固定为:
- 验证 root/shard 都是非 symlink 私有目录;日志与 truncation fact/temp 必须是有界普通文件,遗留 quota 辅助项必须是 FIFO;
- 先删除日志,成功或已缺失后再删除 FIFO 与 truncation fact/temp,最后 fsync shard directory;日志删除失败时必须保留解释它的 truncation fact;
- 写入 tombstone。
不能先写 tombstone:断电后数据库可能永久声称已清理,但文件仍存在。若在 unlink 后、tombstone 前崩溃,下一 sweep 将文件识别为 already absent 并补写 tombstone。并发清理时第一条同 Attempt/Artifact/finished identity 的证据获胜;身份不同稳定冲突。
Retention 不清空 RunAttempt.logArtifactId。历史 Run 仍保留 opaque ownership 和“已过期”解释能力,日志读取端可通过 tombstone 区分从未产生、暂时缺失和已执行 retention。
7. cluster 共享语义,不共享本地文件实现
- edge/standalone:SQLite candidate/tombstone + 私有本地文件 + statfs capacity;
- cluster-control:PostgreSQL metadata/fencing + object-store lifecycle/delete marker + Project policy;
- worker:本地 spool quota 与上传 ACK 必须先于删除,不能把控制面 tombstone 当作 Worker 本地清理证明。
对象存储 adapter 必须复用 terminal settlement、pressure policy、bounded page、delete-before-marker 和 immutable identity contract;共享挂载本地 Artifact root 不是 cluster 实现。
8. 当前保持 production unreachable
quota-aware 3.0 allocator、durable FIFO drainer、正/负 truncation fact、capacity probe、0015/0016 migration、SQLite candidate/tombstone/CAS checkpoint、文件 retirement、单页 service、profile policy 和显式 lifecycle 已实现,但没有接入默认 Dispatcher/Primary startup。生产启用前仍需:
- 配置/API 中的 Project/Profile policy 与变更审计;
- lifecycle startup/shutdown 装配、指标 exporter、持续低水位告警和 admission 联动;
- 真实 Alpine/Debian 多架构验证
head/wc/FIFO/信号/父进程退出; - loopback/tmpfs 上的 ENOSPC、inode 耗尽、断电/fsync 和并发外部写入测试;
- 日志读取 API 对 tombstone、
quotaReached=true|false|unknown和权限的稳定响应; - PostgreSQL/object store/Worker spool contract suite。
因此 migration 和 adapters 存在不代表 retention timer 已启动,也不改变现有 2.x/manual Primary 的日志策略。
影响
正面影响:
- 单 Attempt 无法把本地日志写过明确上限,durable 模式在控制面崩溃后仍受限;
- edge 在创建任务前保留系统盘安全余量,并能在低水位使用更短但显式的保留期;
- retention 不扫描未知目录,不误删 active/lost/未结算 receipt,并能从 unlink→DB 崩溃窗口恢复;
- 历史 Run 保留 opaque Artifact identity 和不可变清理证据。
代价与风险:
- durable quota 依赖 POSIX FIFO、
head -c和mkfifo,必须持续进入镜像 contract; - quota 后继续排空会消耗少量 CPU/pipe I/O,但避免改变用户任务退出结果;
- statfs admission 不是空间预留锁,其他进程仍可能导致 ENOSPC;
- tombstone 与 RunAttempt 同生命周期增长,未来删除 Run 时由外键级联,不能独立无限保留;
- lost Artifact 暂不自动清理,可能需要人工审计或后续 settlement 协议。
未选择的方案
- 只限制 Node sink:控制面崩溃后的 durable FD 绕过,拒绝。
- 使用
RLIMIT_FSIZE:影响任务自己的其他文件并可能改变退出结果,拒绝。 - quota 达到后关闭 FIFO reader:向用户进程制造 SIGPIPE,拒绝。
- 递归扫描日志目录并按 mtime 删除:没有 Run/Attempt/receipt ownership,拒绝。
- 把 lost 当作可清理终态:无法证明孤儿进程退出,拒绝。
- 先写 tombstone 再 unlink:断电可能形成永久泄漏,拒绝。
- 低水位时删除 active 或无最短保留:破坏恢复和用户契约,拒绝。
- cluster 共享本机目录/SQLite:无多副本 fencing 和对象生命周期,拒绝。
验证要求
- 普通 sink 和 durable launcher 对 oversized/replay 输出都精确停在最大字节;
- quota 后 child 继续完成、退出码/receipt 不变,capability 环境不进入 child;
- 未超额发布
quotaReached=false、超额发布true、fact 缺失保持unknown,不得从文件大小推断; - capacity reserve 在创建日志文件前拒绝,非法 policy/snapshot fail closed;
- candidate 排除 active、legacy、non-local、pending receipt、tombstoned 和 lost;
- normal/pressure retention、stable cursor、page/delete budget 可重复验证;
- lifecycle 单 timer、无重叠、有界 stop、CAS fencing、idle 零 checkpoint 写和低敏 JSON-safe summary 可重复验证;
- symlink/非普通文件/非 FIFO 辅助项 fail closed,不影响外部目标;
- unlink 后 tombstone 失败可在下一 sweep 收敛为 already absent;
- migration、ownership、Node 22/24 全量测试通过;
- 真实 edge 文件系统补充 ENOSPC、inode、断电和写放大报告后才允许生产装配。