把 checkpoint 分支写实为 at-most-once 硬约束而非续跑能力

- worker.rs 模块注释明确 Tripo job 固定 max_attempts = 1,崩溃后租约耗尽直接终态失败、不会被重新 claim,因此不存在续跑路径
- worker.rs 说明 existing_checkpoint 是防御性硬约束,checkpoint 的日常用途是人工对账
- 技术方案与 ADR 把「崩溃后重新 claim 只查询」改写为「不重新 claim,带 checkpoint 的路径绝不二次 submit」
- 实施计划与里程碑同步 checkpoint 语义,不再宣称续跑
This commit is contained in:
2026-09-23 17:50:04 +08:00
parent 85219ad1d4
commit 985955205c
5 changed files with 18 additions and 10 deletions
@@ -6,7 +6,7 @@ Tripo 3D 生成以**全新 API** 接入 api-server,不复用 `platform-hyper3d
选择这个边界是因为第三方 SDK 的任务与错误模型会随 provider 变化,一旦穿透到 API 与前端就会把手第三方约束固化成产品契约;同时 Hyper3D 与 Tripo 的参数、状态与计费形态都不同,把它们塞进同一组路径会让“路径名”与“实际 provider”不一致,调用方无法分辨。
**at-most-once submit**worker 的崩溃恢复模型是“租约过期后重新 claim,handler 从头重跑”,这对可重跑的 provider 安全,但 Tripo 重跑会二次 submit、二次消耗额度。因此 `external_generation_job` 追加 provider checkpoint 字段并新增受租约栅栏保护的写入 procedure:没有 checkpoint 才允许 submitsubmit 成功后必须先落 checkpoint 再轮询,已有 checkpoint 只允许查询、下载与落库。submit 成功但 checkpoint 落库失败的 attempt 只能终态失败,不得退回可重试队列。checkpoint 不写入 `request_payload_json`,因为该字段是请求真相并参与请求指纹与压缩逻辑。
**at-most-once submit**其它生成任务的崩溃恢复模型是“租约过期后重新 claim,handler 从头重跑”,这对可重跑的 provider 安全,但 Tripo 重跑会二次 submit、二次消耗额度。Tripo job 固定 `max_attempts = 1`,崩溃后租约耗尽直接终态失败、不重新 claim,因此没有续跑路径。因此 `external_generation_job` 追加 provider checkpoint 字段并新增受租约栅栏保护的写入 procedure:没有 checkpoint 才允许 submitsubmit 成功后必须先落 checkpoint 再轮询,已有 checkpoint 只允许查询、下载与落库(防御性硬约束)。submit 成功但 checkpoint 落库失败的 attempt 只能终态失败,不得退回可重试队列。checkpoint 不写入 `request_payload_json`,因为该字段是请求真相并参与请求指纹与压缩逻辑。
**提交时定价与真实扣费**:Tripo 计费形态是“底价 + 可叠加 add-on”,现有“模型 → 档位 → 单价”查表表达不了,因此新增 `model3d` 定价段,全部以泥点计价,底价按 `endpoint × modelVersion × 是否有贴图` 拆分,add-on 按请求参数判定叠加;配置加载即校验全部底价键存在,运行期缺键直接拒绝提交,不复用现有 `unwrap_or(0)` 兜底。扣费依据固定为提交时定价,而不是 provider 返回的实际消耗:用户在提交前即可得到确定价格,失败退款就是 attempt 级单次冲正,不需要按实际用量退差额。provider 的实际消耗只写日志用于成本对账,不进入契约、API 响应或资源行。
@@ -59,11 +59,11 @@ Milestone Spec: `docs/project-memory/plans/【里程碑】Tripo生成Worker执
- 执行分支:无 checkpoint 才 submitsubmit 成功后先落 checkpoint 再轮询;已有 checkpoint 只查询、下载与落库;checkpoint 写入失败按失败收口。
- 轮询在单次 attempt 内进行并按现有心跳续租,超时按现有 worker 预算语义处理。
- provider 成功但落库失败按失败收口并冲正扣费,checkpoint 保留供对账。
- 验收:崩溃重放不产生第二次 submit;落库失败不退化成“已交付”。
- 验收:任何带 checkpoint 的执行路径都不产生第二次 submit;`max_attempts = 1` 使崩溃后不重新 claim(不存在续跑);落库失败不退化成“已交付”。
7. **接线与文档**
- `app.rs` merge 新路由;`external_generation_worker.rs` 增加两个分支并纳入长任务超时;`tripo3d/mod.rs` 去掉里程碑一的 `dead_code` 豁免。
- 主规范把 `max_attempts = 1` 与 checkpoint 的实际语义写实,里程碑状态同步。
- 主规范把 `max_attempts = 1` 与 checkpoint 的实际语义写实at-most-once 硬约束 + 人工对账,不是续跑能力),里程碑状态同步。
- 验收:`node scripts/check-doc-index.mjs``npm run check:encoding``git diff --check` 通过。
## 进展
@@ -14,7 +14,7 @@ Implementation Plan: `docs/project-memory/plans/【实施计划】Tripo生成Wor
- 两个 Bearer 路由:`/api/assets/tripo/text-to-model``/api/assets/tripo/image-to-model`,含 `Idempotency-Key` 校验与错误映射。
- 两个 Tripo job kind 的 worker 分支:submit、单次 `get_task` 轮询、下载、OSS 写入、资源登记、严格结果序列化。
- checkpoint 规则:无 checkpoint 才 submitsubmit 成功后先落 checkpoint 再轮询,已有 checkpoint 只续跑submit 成功但 checkpoint 落库失败的 attempt 终态失败。
- checkpoint 规则:无 checkpoint 才 submitsubmit 成功后先落 checkpoint 再轮询,已有 checkpoint 只查询(防御性硬约束,当前 `max_attempts = 1` 下不存在续跑路径)submit 成功但 checkpoint 落库失败的 attempt 终态失败。
- 图片输入的归属校验与 OSS 对象解析;结果落点的两个分支。
- 提交时定价扣费、attempt 级退款、幂等重放不重复扣费。
- 真实 Provider smoke 与端到端验收证据。
@@ -75,14 +75,14 @@ canvasCompletion? 画布占位框回填,只在项目资源落点下生效
本能力复用现有 `external_generation_job` 队列、worker、租约与 owner 过滤,不新建平行队列、不新增状态枚举、不新增查询接口。状态对外仍是 `queued` / `running` / `completed` / `failed`,阶段提示沿用现有 `phase``phaseLabel` / `phaseDetail`
**at-most-once submit** 是本能力与其它生成任务的关键差异。现有 worker 的崩溃恢复模型是“租约过期后由别的 worker 重新 claim,handler 从头重跑”,这对可重跑的 provider 安全,但 Tripo 重跑会二次 submit、二次消耗额度。因此:
**at-most-once submit** 是本能力与其它生成任务的关键差异。其它生成任务的崩溃恢复模型是“租约过期后由别的 worker 重新 claim,handler 从头重跑”,这对可重跑的 provider 安全,但 Tripo 重跑会二次 submit、二次消耗额度。Tripo job 固定 `max_attempts = 1`,任何 attempt 失败都是终态失败,崩溃后租约耗尽同样直接终态失败、不会被重新 claim,因此不存在“重新 claim 后继续查询”的续跑路径 —— checkpoint 是 at-most-once 的硬约束与人工对账凭据,不是续跑能力。因此:
1. 没有 `providerTaskId` 的 job 才允许 submit
2. submit 成功后必须先把 `providerTaskId` 写回 checkpoint,再进入轮询;
3. 已有 `providerTaskId` 的 job 只允许 `get_task`、下载与落库,绝不允许再次 submit;
4. submit 成功但 checkpoint 写入失败的 attempt 只能终态失败,不得退回可重试队列,否则重试会二次 submit;该 job 保留脱敏对账信息供人工处理。
失败重试沿用现有语义:`fail` 先按 attempt 冲正扣费,`attempt < maxAttempts` 时回到 `pending` 并延迟重试,否则终态 `failed`
失败重试沿用现有语义:`fail` 先按 attempt 冲正扣费,`attempt < maxAttempts` 时回到 `pending` 并延迟重试,否则终态 `failed`Tripo job 固定 `max_attempts = 1`,这条路径退化为“第一次失败即终态”,永不进入延迟重试。
**provider 成功但落库失败按失败处理**job 失败、该 attempt 退款,客户端看到 failed。没有正式资源引用就不算交付成功;checkpoint 保留,人工对账可证明这次生成确实发生过。由此产生的“provider 已消耗、用户已退款”净亏损是已知并接受的成本。
@@ -196,7 +196,7 @@ width/height = 预览图像素尺寸
3. 定价配置缺任一底价键时服务启动失败;请求命中 add-on 后扣费等于底价加全部命中 add-on;`fast``standard` 不加价。
4. 组合校验前置于 provider 副作用:`texture=false` + `textureQuality``generateParts=true``texture=true` 的同现请求被拒绝且不扣费。
5. image-to-model 的 `source` 元数据预检前置于扣费与入队:跨 owner、未登记、已删除与 `kind` 不符的引用返回 400,不产生 operation、不扣费,也不把图片正文读进内存或调用 provider。
6. worker 崩溃后重新 claim 不会产生第二次 submit:已有 checkpoint 的 job 只轮询、下载与落库。
6. Tripo job 固定 `max_attempts = 1`:崩溃后租约耗尽即终态失败、不重新 claim,不存在续跑路径;同时任何带 checkpoint 的执行路径都只轮询、下载与落库,绝不二次 submit(防御性硬约束,不依赖配置保证)
7. submit 成功但 checkpoint 写入失败的 attempt 终态失败且不自动重提。
8. provider 成功但落库失败时 job 失败、该 attempt 退款,checkpoint 保留供对账。
9. worker 落库时构造的完成结果按端点严格类型化,只含模型与预览的正式资源引用,不含 provider task ID 与带签名的临时 URL;标准消费者的队列结果不透传它(与其它画布生成任务一致,结果靠画布 / 素材读回)。
@@ -2,8 +2,12 @@
//!
//! 与其它生成任务的关键差异是 **at-most-once submit**provider 不接受幂等重放,
//! 所以只有“没有 checkpoint 的 job”才 submitsubmit 成功后必须先落 checkpoint 再轮询;
//! 单次尝试(`max_attempts = 1`)失败即终态收口,不存在第二次 submit 的窗口,
//! checkpoint 只用于继续查询与人工对账。
//! 单次尝试(`max_attempts = 1`)失败即终态收口
//!
//! 注意这不是「崩溃后接着查询」的续跑能力:3D job 的 `max_attempts = 1`,租约耗尽的 job 会被
//! 直接判终态失败、不会被重新 claim,所以不存在「重新 claim 后继续查询」的真实路径。
//! [`existing_checkpoint`] 的分支是一条**防御性硬约束**(任何带 checkpoint 的执行都绝不会
//! 再 submit 一次),checkpoint 的日常用途是人工对账。
use std::time::{Duration, Instant};
@@ -131,7 +135,11 @@ async fn run_model3d_job(
.await
}
/// checkpoint 是 provider 侧任务的唯一凭据有值就只能续跑查询
/// checkpoint 是 provider 侧任务的唯一凭据,也是 at-most-once 的开关:有值就只能查询
/// 绝不允许再次 submit。
///
/// 当前 `max_attempts = 1` 下 job 不会被重新 claim(租约耗尽直接终态失败),所以这里守住的
/// 是一条防御性路径;它存在的意义是把「不许二次 submit」写成代码里的硬约束,而不是靠配置保证。
fn existing_checkpoint(job: &ExternalGenerationJobRecord) -> Option<String> {
job.provider_task_id
.as_deref()