副作用边界上的幂等
面向 VOZEB PRO。数据流里「处处幂等」不是一个 HTTP 头搞定全站,而是:每一种花得起钱、写得进库、会惊动上游的动作,各自有「再来一次 = 返回上次」的键。 失败则把下一次敲门的时间写进同一行,而不是再开一张新单。
0. 什么时候用这套判断
适合:
- ✅ 用户连点「生成」、刷新、重试、关页面后再打开
- ✅ 上游 webhook / 支付回呼可能重放
- ✅ Worker 或
after()会对同一任务敲很多次 - ✅ 扣积分、退积分、创建上游任务——任何做两次会双倍花钱的地方
不必硬套:
- ⚠️ 只读列表、打开一张已有图(那是读路径,见 07)
- ⚠️ 主题切换、antd 表单校验这类没有外部副作用的动作
在 VOZEB PRO 中的定位: 任务用
clientRequestId去重;钱包每笔消费/退款带服务端idempotencyKey;支付回呼按事件 id 去重;上游只对一条 attempt create 一次,其余只 poll。四件事键不相同,不要混用。
1. 先问:再执行一次,会多付什么
幂等要守的是副作用边界,不是「函数再进一次」。
| 再做一次会多出什么 | 本仓库的键 | 重复时应该怎样 |
|---|---|---|
| 多一张任务 / 多一次调度 | clientRequestId(进 Route / 调度前) |
返回已有 run / 任务,created: false |
| 多扣或多退一笔积分 | 服务端生成的 idempotencyKey |
applied: false,返回上次流水 |
| 多付一笔钱 / 多发一次货 | 供应商事件 id、订单支付幂等 | 当已处理,不改第二次余额 |
| 上游再 create 一次(双倍账单) | attempt 上记下的上游 id | 已有 id → 只 query;从未提交才 create;说不清 → needs_review |
浏览器带来的 Idempotency-Key 不能直接当钱包流水号。用户能改、能重放、能和别人撞。计费键由服务端生成(AGENTS.md)。
2. 上游:create 一次,poll 很多次
这是数据流「处理」段的核,也是和 Worker 篇的接缝。
startGenerationAttempt / finishGenerationAttempt 把「对某一个渠道候选的一次尝试」记下来:渠道、模型、积分流水、成败。runtime 的规矩是:
- 还没有上游 id、且确认没提交过 → create 一次,把 id 写回任务
- 已有上游 id → 只 poll / query 这个 id
- 不确定有没有创建成功 → 不要赌,进
needs_review,等人或明确策略
关标签、热更新、换副本、Worker 再领,走的都是 2,不是 1。
「用户对同一失败结果点重试」才开新的 attempt(新的上游尝试),并沿用原来的会话 / 生成记录 / 结果槽身份——这是产品语义上的第二次,不是网络重试。
不要用提示词文本当去重键。相同文案点两次,本仓库视为两次独立提交。
3. 任务、钱包、回呼:三套键三套表
任务 / Agent run
normalizeCreativeRunRequest → 按用户 + clientRequestId 查找。已存在就不再 schedule。去重发生在建任务之前,不是写完再删。
钱包
consume / credit / refund / 管理员调整都要带 idempotencyKey。Postgres 路径用唯一约束把「同一键第二下」变成读上次结果。返回里有 applied:false 表示这是回放,余额已经动过了。
支付 / 供应商回呼
按事件 id 去重。回呼网络抖动是常态;处理函数必须假设「同一条事件会来三遍」。
三套键不要合并成一个「全局请求 id」:一次用户点击可能对应「一个 clientRequestId + 一笔扣款键 + 若干次 poll(零次新的上游 create)+ 以后可能一笔退款键」。生命周期不同。
4. 失败:约下次,不另开一单
非终态任务会写未来的 nextPollAt。恢复批次(页面 after() 和 Worker 共用 runGenerationTaskRecoveryBatch)到期再领同一行。
连续错误指数退避:大约 5s 起,封顶 60s,避免把上游打穿。租约过期后任一 lane / Worker 都能领——领的是同一行,不是 insert 一行「重试任务」。
对照:
网络失败 / 进程死 → 同一任务行,nextPollAt 往后推
用户明确点重试 → 同一记录槽,新 attempt
用户再点一次生成 → 新 clientRequestId,新任务行
三种「再来」看起来都像 retry,键和行完全不同。写补丁前先说清是哪一种。
5. 和「读」的边界
幂等管的是写。读一张已登记的图、刷新任务状态、打开作品页,不需要再发明一套幂等键。那些请求可以重入,因为它们不 create 上游、不扣积分。
若一个「刷新」接口里偷偷 create 了上游或又扣了一次钱,那是接口分类错了,不是少写了一个头。
6. 写代码时的五问
- 这段逻辑再进一次,会多一笔钱、多一次上游、还是只多一次查询?
- 重复请求应该返回哪条已有记录?找不到这条记录,说明键还没落库。
- 键是服务端发的,还是客户端说了算?钱必须是前者。
- Worker 再敲门时,会不会走到 create 分支?会,先补「已有 upstream.id 只 poll」。
- 用户点的是「重试」还是「再生成一次」?槽位身份和 attempt 身份不要混。
7. 读完能指挥自己(或 AI)做什么
- 「自动恢复路径禁止再次创建上游任务;已有 upstream.id 只许 query。」
- 「扣积分必须带服务端
idempotencyKey;重复返回上次流水,applied: false。」 - 「
clientRequestId已存在则created: false,不要按提示词文本合并两次提交。」 - 「失败写
nextPollAt并退避,不要 insert 一行新的『重试任务』。」
相关文档
- 稳定键与会过期的位置
- 读路径是门卫
- 架构决策的思维框架
- 旁路 Worker 的本质
docs/learning/CH-06-生成任务底座.mdStudyVault/01-架构/数据流.mdweb/src/lib/server/generation-attempt.tsweb/src/lib/server/points-wallet-service.ts