用户点「批量生视频」,10 张已经出好的分镜图被整套重跑,再扣一遍钱。
生产数据核实过:两次运行的 image.generate 都是 charged。不是显示问题,是真的扣了两次。
修的过程里翻出两处独立成因,以及一条让它们同时隐身的原因。
一、祖先闭包不该当目标传
画布上有一个「运行整组」的入口。前端传目标节点给后端:
onRunGroup={
onRunTargets && runState && !['estimating', 'submitted', 'running'].includes(runState)
? (groupId) => {
const targetNodes = ancestorClosure(group.nodeIds, flow.edges);
onRunTargets(targetNodes);
}
: undefined
}
它把组内节点连同祖先闭包一起传了过去。
后端的复用逻辑是这样的:reuseCandidates = runSet − targetSet。已经在目标集合里的节点不会被复用,要重新执行。祖先一旦也算目标,复用候选就成了空集——本来该被复用的上游,全部重跑。
而那 10 张分镜图正是祖先。
修法是把这一步交回给后端:
? () => {
// 只传组内节点当目标,**绝不能把祖先闭包当目标传**:
// 后端 reuseCandidates = runSet − targetSet,祖先若也算目标,
// 复用候选就成了空集,已出图的上游会被整套重跑并再次扣费
// (线上真实事故:点「批量生视频」把 10 张分镜图重扣了一遍)。
// 祖先闭包由后端 pruneFlowForTargets 自己算。
onRunTargets(group.nodeIds);
}
祖先闭包本来就由后端的 pruneFlowForTargets 算。前端多算一遍,算的不只是重复劳动,而是一个语义不同的集合:后端要的是「用户想跑的」,前端给的是「跑这个需要的」。
补的守卫测试很直白——组件层的回调只能接收一个组 ID:
it("运行整组时只把组内节点当目标,不能传祖先闭包(线上重复扣费事故)", () => {
fireEvent.click(screen.getByRole("button", { name: "整组执行" }));
expect(onRunGroup).toHaveBeenCalledWith(mockGroup.id);
expect(onRunGroup.mock.calls[0]).toHaveLength(1);
});
同一处 ancestorClosure 在运行面板里保留着,那里是用它显示「运行 N 个(复用 M 个)」的,不参与提交。
二、落库的行对不上
第二处成因更早,也更深。
画布运行会把每个节点展开成若干行写入 CanvasFlowNodeRun。批量框要求一个节点跑 N 份,所以展开后的行需要一个复合标识:
const nodeRuns = expandedNodes.map((node) => ({
id: generateNodeRunId(),
runId,
nodeId: node.nodeId, // 展开后的复合 id
batchIndex: node.batchIndex,
// ...
}))
问题在这张表的唯一约束是 @@unique([runId, nodeId, batchIndex]),而 nodeId 落的是展开后的复合 id。
于是 executor 那一侧全线对不上。它按 nodeId + batchIndex 反查行、统计每份的项数、构建调度状态——落库的键和它查的键不是一套。
调度器找不到已派发的记录,于是重复派发同一个批量单元。周期的量级是 100 毫秒。
修法是把复合 id 留在内存里,落库只落原始 ID:
// 行必须以「原始 nodeId + batchIndex」落库(对齐 @@unique([runId, nodeId, batchIndex]))。
// 展开后的复合 id 只活在调度器内存里:落了复合 id,executor 的
// itemCountsFromRows/buildSchedulerState 就全都对不上行。
配套改了 executor 三处状态写库的定位方式,并在 ready 循环里加了一层 in-flight 防重派兜底,防止同类问题再犯。
三、为什么单测全绿
这一处的成因能藏住,是因为测试的写法。
批量链路的每个环节都有自己的单测:创建函数有自己的用例,executor 也有。两组用例各自手写 fixture——创建函数的用例断言它写出了正确的行,executor 的用例喂给它一组正确的行,断言它正确调度。两组都绿。
错的正是「创建函数写出的行」和「executor 期望的行」之间的那个接口。
修的时候补了一个贯通测试环境,理由写在文件头:
存在的意义是让「写读贯通」测试成为可能——行由真实的创建函数产生、由真实的 executor 消费,
中间不允许手写 fixture(批量行约定矛盾就是靠各自手写 fixture 的单测互相全绿才漏网的)。
新的契约测试从创建一路跑到执行,中间不插桩。断言的是端到端的账:itemCount=3 时,成员 3 行、每份恰好执行一次。
四、项数从哪来
修完上面两处,还有一个数对不上:预估和实扣。
批量框跑几份,原先是从上游推断的:
if (upstreamNode.nodeDefId === 'material.input') {
const count = (upstreamNode.params as any)?.count ?? 1;
// ...
}
注册表里从来没有 material.input 这个节点——真名是 asset.input。这个分支从未命中过,项数永远回落 1。
改成框上显式配置:
/**
* 框内子图跑几份(1~50,缺省 1)。配在框上、由用户手动设置——
* 「按上游 list 项数自动展开」的推断从未走通过(框架节点没注册),
* 手动份数是当前唯一的项数来源。
*/
itemCount: z.number().int().min(1).max(50).optional(),
同时把预估也切到同一个来源:
/**
* 批量倍数:与执行侧同源——run-create 落行的份数就是框上的 itemCount,
* 预估必须用同一个数,否则「预计 1 份、实扣 3 份」。
*/
export function batchMultipliersFromFlow(flow: CanvasFlow): Record<string, number> {
创建、预估、重试三个路由统一用这个函数。
同一轮里还修了预估的一个老问题:视频节点的预估用的是后台的一口价资源键 canvas_video_generate,而执行侧用的是「模型 + 分辨率 + 是否带上游视频」算出的键,按秒计价。两套键,两套价——预估和实扣自然对不上。预估改用执行同款键。
以及一处显眼的占位:
// 改前
const totalEstimatedCost = 0; // TODO: Pass in actual estimate
运行记录里的预估成本一直是 0。
五、续跑为什么不重复扣费
同一轮加了单节点重试。既然重复扣费是这个月的主线,这条功能的实现方式值得记下来。
失败运行的续跑不是「重新跑一遍」:
/**
* 单节点重试:给失败/被取消的运行造一个「续跑」运行。成功节点的行原样回填
* (产物、billingRef、时间戳都保留)——executor 的调度器见到 succeeded 行
* 会直接当作上游已就绪,不会重新执行,也就不会重复扣费;其余节点
* (failed / cancelled / pending)重置成全新的 pending 行,正常调度重跑。
* 不修改原运行:重试是一条新的 CanvasFlowRun,历史记录保持完整。
*/
关键在于复用判定落在行状态上,而不是「这次运行是新是旧」。所以续跑不需要额外的豁免逻辑:被判成功的节点带上原来的 billingRef,调度器看到它就不再执行。
预估也跟着只算子集,余额检查同理。
同一次改动里还补了一个幂等入口——按 userId + clientRequestId 查重,重复提交返回已有的运行 ID。归属校验用带 userId 的查询。
界面文案也改了,从「重试」改成「重试失败节点」,带一句说明:
成功节点的产物直接沿用,只有失败的节点会重新执行并计费。
六、重复扣费这道题
两处成因的形式完全不同:
- 一处是前端算了一个它不该算的集合。祖先闭包的计算本身没错,错在把它当成了「目标」。
- 一处是落库的键和查库的键不是一套。两边各自都有定义,各自都有测试,只是没人对着比过一次。
两条链路的共同点是:扣费发生的时刻离「用户点了什么」很远。用户在界面上点的是一个按钮,扣费发生在调度器认为某个节点未被执行的那一刻。中间隔着目标集合的计算、行的展开、行的落库、调度状态的重建。
链路上每一段都「合理」,叠起来就是收两次钱。
防守这类问题的办法不是加校验,是把口径收成一份:
- 目标集合由后端算,前端只传用户选了什么。
- 批量份数由框上配置,预估与执行读同一个函数。
- 复用与否由行状态决定,续跑沿用这套判定,不加例外。
- 贯通测试不允许手写 fixture——两侧各自造数据,就只能测出两侧各自的正确性。
最后那条是这次最实在的收获。一组测试全绿但系统是错的,通常不是因为用例写得不好,而是因为用例的输入各自构造。第三方造的数据一定和另一方一致,因为它们都来自同一个人的同一份理解;真实的接口不一致,恰恰是因为两边由不同的人、在不同的时间实现。
■