Files
qinglong/docs/adr/ADR-0026-local-artifact-quota-and-retention.md
T

12 KiB
Raw Blame History

ADR-0026:本地 Artifact 硬配额、磁盘水位与可恢复 Retention

  • 状态:Proposed
  • 日期:2026-07-18
  • 关联:QL-RFC-0001、ADR-0007、ADR-0021、ADR-0024、ADR-0025

2026-08-12Local 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 ExecutionOutputSinkstdout/stderr 使用继承的文件描述符,控制面崩溃后 launcher 和用户进程仍继续写。Linux/POSIX 没有通用的 per-file-descriptor byte quotaRLIMIT_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 exceededExecutor 继续排空 child stream 并产生 bounded output-sink diagnostic。

durable 模式把 maximumBytes 作为非枚举 adapter capability 交给 launcher。Node 仍以 O_APPEND|O_NOFOLLOW 安全打开日志并传递 FD;launcher 在同一私有 shard 创建确定性的 0600 FIFO,启动一个有界 drainer

  1. head -c remaining 只把剩余额度写到继承的安全 FD
  2. 同一 reader 用 wc -c 排空剩余字节,只根据计数判断是否真正达到 quota,不保存用户内容;
  3. stream 关闭后始终发布固定格式、不可覆盖的 truncation fact,记录 Run/Attempt/Artifact、实际 quota、quotaReached=true|false 和 observation timefact 缺失只能解释为 unknown,禁止推断为未截断;
  4. 用户进程不会因 reader 退出收到 SIGPIPE,退出码和 completion receipt 语义不因日志截断改变;
  5. 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 镜像已有 coreutilsDebian 必须在镜像契约中持续验证 head -cwc -cmkfifo。遗留 manual Primary 未提供 maximumBytes + logArtifactId 时保持旧 direct-file 路径,不被本切片静默切换。

3. Retention 只相信数据库终态和 receipt settlement

SQLite candidate source 只返回同时满足以下条件的记录:

  • Run execution_owner=runtimeRun 与 Attempt 均为 succeeded|failed|cancelled|timed_out
  • Executor 为 local_processlog_artifact_id 是 canonical local-*
  • 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 本身仍按幂等协议收敛;
  • 一个 unref timer、无重叠 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) 索引。

操作顺序固定为:

  1. 验证 root/shard 都是非 symlink 私有目录;日志与 truncation fact/temp 必须是有界普通文件,遗留 quota 辅助项必须是 FIFO;
  2. 先删除日志,成功或已缺失后再删除 FIFO 与 truncation fact/temp,最后 fsync shard directory;日志删除失败时必须保留解释它的 truncation fact
  3. 写入 tombstone。

不能先写 tombstone:断电后数据库可能永久声称已清理,但文件仍存在。若在 unlink 后、tombstone 前崩溃,下一 sweep 将文件识别为 already absent 并补写 tombstone。并发清理时第一条同 Attempt/Artifact/finished identity 的证据获胜;身份不同稳定冲突。

Retention 不清空 RunAttempt.logArtifactId。历史 Run 仍保留 opaque ownership 和“已过期”解释能力,日志读取端可通过 tombstone 区分从未产生、暂时缺失和已执行 retention。

7. cluster 共享语义,不共享本地文件实现

  • edge/standaloneSQLite candidate/tombstone + 私有本地文件 + statfs capacity
  • cluster-controlPostgreSQL 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 -cmkfifo,必须持续进入镜像 contract
  • quota 后继续排空会消耗少量 CPU/pipe I/O,但避免改变用户任务退出结果;
  • statfs admission 不是空间预留锁,其他进程仍可能导致 ENOSPC;
  • tombstone 与 RunAttempt 同生命周期增长,未来删除 Run 时由外键级联,不能独立无限保留;
  • lost Artifact 暂不自动清理,可能需要人工审计或后续 settlement 协议。

未选择的方案

  1. 只限制 Node sink:控制面崩溃后的 durable FD 绕过,拒绝。
  2. 使用 RLIMIT_FSIZE:影响任务自己的其他文件并可能改变退出结果,拒绝。
  3. quota 达到后关闭 FIFO reader:向用户进程制造 SIGPIPE,拒绝。
  4. 递归扫描日志目录并按 mtime 删除:没有 Run/Attempt/receipt ownership,拒绝。
  5. 把 lost 当作可清理终态:无法证明孤儿进程退出,拒绝。
  6. 先写 tombstone 再 unlink:断电可能形成永久泄漏,拒绝。
  7. 低水位时删除 active 或无最短保留:破坏恢复和用户契约,拒绝。
  8. 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、断电和写放大报告后才允许生产装配。