Files
qinglong/docs/adr/ADR-0168-durable-model-invocation-step-run-fence.md

9.7 KiB
Raw Permalink Blame History

ADR-0168Durable ModelInvocation 与唯一 StepRun Fence

  • 状态:Accepted
  • 日期:2026-07-26
  • 关联:RFC D-12、D-13、D-156、D-157ADR-0156、ADR-0167、ADR-0170

背景

Model Gateway 已经能执行受预算约束的远程调用,但进程内 audit 不能回答崩溃、 COMMIT 响应丢失、集群 promotion 或用户取消后的 durable outcome。另建一套 Invocation pending/running/succeeded 状态会与既有 Run/StepRun 产生双写;自动重试 结果未知的远程模型调用又可能重复计费,并产生不同输出。

同时,禁用 AI 的路由设备不应因为 workspace 中存在 AI 包就创建 AI 表、加载 adapter 或增加默认 Profile 产物。持久化能力需要复用现有 SQLite/PostgreSQL 部署边界,但 不能再拆一个只有 repository 的 package。

决策

1. ModelInvocation 是 model StepRun 的 receipt,不是第二状态机

每次 invocation 必须绑定一个 StepRun.kind=model

  • admission 原子提交 ready → running、Run version/event、RunEvent、 StepRunMutation 和 ModelInvocationStart
  • completion 原子提交 usage/cost/byte receipt 与 running → succeeded|failed|timed_out|lost
  • succeeded 的 StepRun 只保存 outputRef=model-invocation:<id>,正文默认不进入 Start、Completion、RunEvent 或 mutation
  • deadline 映射为 timed_out
  • provider 调用后的 caller abort、consumer stream cancel 或其它结果未知映射为 lost/outcome_unknown,只允许人工裁决,不自动 replay。

Start/Completion/Command 使用独立 v1 schema 和 domain-separated SHA-256 digest 单 record/command JSON 最大 24 KiB。完整绑定 Project、Run、StepRun、Trace、provider、 model、policy revision、request digest、deadline、输入/输出字节、token 与可选费用。

2. mutation identity 必须由摘要派生

requestId 最长 128 字符,不能用 requestId + ':started' 生成事件 ID。 createModelInvocationMutationIdentity 以 invocationId、phase 和独立 domain 派生完整 SHA-256,并产生均不超过 128 字符的 mutation/event/dedupe identity。Start 与 Completion normalizer 同时复验 identity、StepRun digest/version、Run event sequence 和 command digest;身份或内容复用漂移一律 conflict。

3. durable admission 不得被 Abort race 脱离

Policy 解析和 provider Promise 可以被 deadline 从调用方视角中止;durable admission 不可以。Gateway 必须等待 audit/start sink 自身的受限数据库操作结束:

  • admission 未成功时 provider I/O 为零;
  • deadline 在 admission 中到达且 start 最终提交时,随后写入 timed_out completion
  • admission sink 失败时返回 MODEL_AUDIT_UNAVAILABLE
  • terminal sink 失败时不把 provider 结果交给上层。

这会让一个超时的 admission 在 repository timeout 内继续占用一个有界并发槽,但避免 后台出现无法解释的 running StepRun。repository 必须有 statement/busy timeout,不能 用 Gateway Promise race 替代数据库取消和事务收敛。

4. migration、repository 与 coordinator 留在可选 AI 能力族

SQLite 和 PostgreSQL feature migration、原子 repository、durable coordinator 与 recovery 都进入既有 @qinglong/ai 的显式 subpath,不新增 workspace package。 双方言实现只依赖 node:sqlite 或 runtime-core 的 structural PostgreSQL Pool/Client port,不导入 Drizzle、pg、local-sqlite 或 cluster-postgres,也不增加 optional peer。具体 Profile 只在启用 AI 时注入已有数据库 authority。

AI schema 使用独立 feature migration stream

  • AI 未启用时不运行 feature migration、不创建表;
  • feature history 使用独立表、stream identity 和 checksum,绝不向双方言 main migration history 插入 feature row
  • PostgreSQL feature tables/history 位于独立 ql3_ai schema,避免被核心 ql3 exact readiness 误判为未知表;SQLite readiness 保留精确 owned-table 校验并允许 同库显式 feature table
  • 启用 composition 必须先显式 migrate/readiness,再构造 repository
  • PostgreSQL runtime role 对不可变 invocation 表只取得 SELECT/INSERT,不得为了 FOR UPDATE 扩大 UPDATE/DELETE
  • repository subpath 只取得数据库 authority,不取得 provider credential 或外部网络。

Gateway admission sink 返回 created|existing。只有 created 可开始 provider I/O existing 包括正常重放和 COMMIT 响应丢失后的耐久回查,必须返回 MODEL_INVOCATION_REPLAY_BLOCKED,禁止再次调用 provider。bounded recovery 只扫描 已过数据库 deadline 且没有 completion 的 start,并收敛为 lost/outcome_unknown,不自动重放模型。

5. unknown outcome 只能以不可变人工 Resolution 推进

lost/outcome_unknown 不再是永久死点,也不能被自动 retry。唯一出口是受信 User 提交一个 qinglong/model-invocation-resolution@v1 receipt,决策严格限定为:

  • retry:原子执行 lost → ready,清除旧结果字段;后续 ready → running 必须 使用新的 invocationIdStepRun attemptCount 在新 start 时增加;
  • fail:原子执行 lost → failed,固定 resultCode=model_outcome_rejected
  • cancel:原子执行 lost → cancelled,固定 resultCode=model_outcome_cancelled

Resolution/Command 绑定原 Completion digest、Project/Run/StepRun/Trace、User ID、 StepRun version/digest、mutation/event/dedupe identity 与时间。repository 必须在同一 SQLite/PostgreSQL 事务内复验原 completion 仍是 outcome_unknown、当前 StepRun 仍 精确等于该 completion 的 lost winner,然后提交 StepRun/Run/RunEvent/ StepRunMutation/Resolution。每个 invocation 只能有一个 Resolution;相同命令 exact replay,不同 User/decision conflict。

一个 StepRun 可以有多个 attempt,因此 Start/Completion 表不能再对 (run_id, step_run_id) 建唯一索引。它们改用包含时间和 invocationId 的普通历史索引, invocation、mutation、RunEvent 和 digest 继续保持唯一。旧 Start/Completion/ Resolution 永不覆盖,新 attempt 形成新的 invocation 链。

被否决方案

  1. 把 adapter 放入双方言 storage package 并反向 optional peer AI:会污染默认 packlist 且真实 Edge artifact 已证明即使 AI 不安装也会增加文件。
  2. 在 AI 包引入 pg/Drizzle:会扩大可选 Edge AI 的依赖和供应链闭包。
  3. 把 durable contract 放入 runtime-core root:禁用 AI 的基础 artifact 也会增长。
  4. 把 AI 表加入双方言 main migration:禁用功能仍产生 schema、备份和写放大。
  5. start audit 使用 Promise.race:调用返回后事务仍可能提交 running 状态。
  6. 对 unknown outcome 自动 retry:可能重复计费且输出非确定。
  7. 保存 Prompt/输出便于调试:默认扩大 Secret、个人数据和备份泄露面。

当前验证

  • @qinglong/ai 默认门:50 pass、1 条 PostgreSQL 条件 skip;真库门另 1 pass
  • 最大长度 requestId 的 mutation/event/dedupe identity 均不超过 128 字符;
  • admission deadline 期间 provider 调用为零,start sink 不脱离,随后写入明确的 deadline failure
  • success、deadline 与 caller-abort 分别收敛为 succeededtimed_outlost/outcome_unknown
  • SQLite 与 PostgreSQL feature history 使用独立表;主迁移与 AI 迁移交错重启通过;
  • SQLite 原子 admission/completion、provider replay block 和 expired recovery 通过;
  • SQLite Edge DELETE/FULL 与 Standalone WAL/FULL 共 14 个真实文件 SIGKILL 窗口通过:10 个 COMMIT 前窗口零部分事实并重放 created4 个 COMMIT 后窗口重启为 durable winner 并重放 existing,全部 integrity_check=ok
  • PostgreSQL 18 真库以 migration/runtime 分角色验证原子事务、exact replay、rollback、 bounded recovery source,以及 invocation 表仅 SELECT/INSERT
  • PostgreSQL ModelInvocation COMMIT 已提交但响应丢失时,provider 调用为 0 coordinator durable recheck 返回 existingrecovery 收敛为 lost/outcome_unknown
  • SQLite 与 PostgreSQL 都覆盖 User retry/fail/cancel 原子 Resolutionretry 后同一 StepRun 可用新 invocationId 进入第二次 runningattemptCount 从 1 增至 2 原 unknown completion 保持不变;不同 decision replay 被拒绝;
  • dependency audit findings=[]AI 只有 runtime-core workspace dependency
  • Edge 基线仍为 3,902,728 bytes/478 files/40 modulesedge-ai 为 4,212,508 bytes/508 files/41 modulesstandalone-ai 为 4,212,580 bytes/508 files/41 modules
  • ADR-0170 已把非空 usage 从 Completion 原子投影为独立不可变 ledger;失败 outcome 也保留已知 usage,无 usage 不伪造零成本,exact replay 同时复验 Completion 与 ledger
  • PostgreSQL 18.4 arm64 完整 HA 门通过 physical streaming、timeline 1→2、fencing、 双 control replica、pg_rewind 与总 passed=trueql3_ai 独立 history 和 Start/Completion/Resolution/UsageLedger 四表在 promotion 前后 9001/9002 checksum、表集与 runtime append-only ACL 完全一致, optionalAiFeatureSchemaSurvivesPromotion=true

后续门禁

  1. credential binding audit 的双方言持久化,以及 binding digest 与 invocation 查询的关联;Project-bound SecretRef 和可清零 material lease 已由 ADR-0169 完成;
  2. ADR-0170 ledger 之上的原子 Project quota、计价 catalog/revision、不可变 rollup 与 retention coverage receipt
  3. AI invocation 数据行级 promotion/partition fault,而不只 schema promotion
  4. 正式产品 importer、配置 ceremony、read-only usage/Copilot/MCP authority
  5. 在这些门禁完成前保持 HTTP/MCP/UI 产品 route 和费用配额关闭。