上一篇(07-22)刚定下「前端全量原样保留、定期人工同步上游」。
二十小时后,「定期同步」这条被废掉了。
时间线可以精确到小时:
| 时间 | 事件 |
|---|---|
| 07-22 14:43 | 接入决策文档定稿,写着「定期人工同步」 |
| 07-22 16:11 | fork 首次进仓库 |
| 07-23 11:20 | 图运行设计文档定稿,前置决策第一条就是脱钩 |
| 07-23 11:42 / 11:57 / 12:32 | 三个提交把「图运行 + 脱钩」一起落进主干 |
从写下调解决定到写下推翻决定,隔了约 20.5 小时。
一、推翻的理由
原文不长,两句:
脱钩上游:不再跟随上游同步。登记文件降级为引入历史存档,「最小改动/过滤不删」纪律废除,前端代码从此按自有代码演进。
而脱钩的书面理由,写在登记文件的顶部:
决策背景:我们的产品形态(服务端图运行 / DAG 调度)与上游的「逐节点手动推演」哲学根本分叉,同步收益趋近于零而冲突成本持续上升;商业授权为纸质合同(含 CLA 再许可权),脱钩无法律障碍。
两句合起来是一条判据:同步的收益,取决于我要不要改它给我的东西。
如果我的改动都在外围——加个鉴权、换个存储——那上游每次更新都是净收益,合进来就行。但如果我要改的正是它的执行语义,那上游的每次前进都会加深冲突,同步从收益变成了成本。
二、具体冲突是什么
三条前置决策,第一条是脱钩,第二条解释了冲突在哪:
- 路线 B + 服务端 Worker:执行层不复用旧自研画布引擎运行时(其端口模型与新前端的松散连线哲学冲突),新写贴合新前端数据模型的图调度;但编排基建照抄旧引擎设计(BullMQ 队列、lease / 恢复、计费包装模式——已验证过)。服务端形态也已被旧画布验证,不做客户端调度中间品。
这句话里有两个反对称的决定,值得拆开看。
不复用的是运行时。 旧自研引擎有一套端口模型:节点声明具名输入输出端口,连接受类型校验,不兼容的端口连不上。新前端没有端口——它的连线就是 { fromNodeId, toNodeId },语义只有「上游产物是下游的素材」。
两套模型接不上。想复用旧运行时,就得给新前端的图补出端口信息,而补不出来——原始数据里没有。
照抄的是编排基建。 队列、租约、心跳、恢复循环、计费包装,这些全部沿用旧引擎的设计。理由写在括号里:「已验证过」。
具体到实现,这一部分是逐项搬的:
| 机制 | 旧引擎 | 新实现 |
|---|---|---|
| 队列名 | canvas-workflow-runs | canvas-graph-runs |
| 租约时长 | 60 秒 | 60 秒 |
| 心跳间隔 | 15 秒 | 20 秒 |
| 恢复循环周期 | 30 秒 | 30 秒 |
| 停滞判定 | 300 秒(队列里待太久就重新入队) | 同 |
| 入队幂等 | jobId = runId | 同 |
| 计费包装 | 台账状态机 planned → charged → settled / refund | 同 |
| 单副本并发 | 2(生产环境 20) | 同 |
租约、心跳、恢复这一套要解决的问题是:调度进程崩了怎么办。做法是让「正在执行」这个状态带一个有效期,执行者定期续期;一旦停止续期,另一个实例就能接管。
计费包装那层解决的是另一件事:调度器不许碰钱。节点执行时通过一个包装过的客户端扣费,包装层记台账,状态机的每一步都落库,于是「扣了没结算」「退了没记」这类状态可以事后对账。
三、保住的边界
脱钩之后,第一期的范围写成了五条目标:
- 画布工具栏新增「运行」:全图运行;选中节点时为「运行到此节点」(只跑该节点及其未完成上游)。
- 服务端 Worker 执行:关浏览器照样跑完,重启可恢复,不重复执行、不重复扣费。
- 节点级状态可视:排队 / 运行中 / 完成 / 失败角标 + 顶部运行进度;失败节点展示可读错误与重试。
- 运行记录落库:快照、每节点状态、产物、计费关联可追踪。
- 结果对账:用户不在线时产生的结果,下次打开画布自动回填。
第二条是这一期的核心。「关浏览器照样跑完」意味着执行必须在服务端,不能在客户端。这也解释了为什么不复用上游的调度——它是给「用户坐在画布前逐个点节点」设计的,那种形态下浏览器就是执行环境。
非目标同样是四条,其中第三条最长:
- 不做多人协同、不做可配置并发上限(沿用「就绪即并发」)。
- 不引入端口 / 类型校验 UI——连线保持上游的松散语义(这是保留该前端的核心理由)。
- 不做运行详情独立页面(进度浮层 + 节点角标够用,详情页二期)。
- 旧自研画布引擎不删除、不改动,仅作参考代码。
第二条是这次决策的边界线。脱钩的是「跟随上游」,不是「保留前端」——保留前端的理由正是它的松散连线,那么这条不能反悔。如果哪天要给连线加类型校验,等于承认保错了,得回到第一天重选。
第四条是给旧引擎的处置:不删、不动。它现在的作用是参考代码——编排基建那部分是照着它抄的。
四、崩溃与重复执行怎么处理
服务端执行最容易出问题的地方是「执行者中途没了」。设计里分成几条路:
- worker 崩溃:租约过期,另一个实例接管。
- 取消:运行置取消,待执行的节点一并置取消;正在跑的视频任务走既有的取消链路。
- 不自动重跑:租约过期后接管的实例只做对账,不重新执行节点。要重跑由用户手动点重试。这一条是「一期从简」,因为自动重跑要先解决「上次到底跑没跑完」。
- 不双扣:每个节点有一个幂等键,任何路径都不产生第二次扣费。
验收标准里有三条是直接对着这些路径写的:
点运行后关闭浏览器,重开画布可见运行完成且产物就位
删除 worker 进程后运行继续完成,无重复扣费
重复点运行(同一请求 id)不产生第二个 run
第二条用的是「删掉进程」这种验证方式,而不是注入一个异常。前者更接近真实故障。
五、纪律怎么废
废止一条纪律,落到文件上是一组具体动作。
登记文件的标题从
# canvas-web 上游同步记录
改成
# canvas-web 上游引入历史(已脱钩,存档)
「上游同步流程」那一节标为已废止,原来的频率说明用删除线划掉,后面加一句「已脱钩,不再执行」。改动清单那一节标题加上「已停止维护」,原来的登记要求划掉,留一句「以下为脱钩前的引入期改动存档」。
引入时那句「前端代码原样保留」也加了限定:「引入初期前端代码原样保留」。
这件事单独占一个实施任务,任务描述里写清了改哪几句、怎么改。把「废止纪律」当成一项要交付的工作,是因为纪律如果只在新文档里宣布作废、旧文件还挂着原有的要求,下一次有人翻到旧文件就会照它做。
六、这不是朝令夕改
隔一天改主意,看上去像反复。区分点在于被推翻的是哪一条。
前一天定的三条里,两条一直没变:前端全量保留、不修改它的设计与交互。变的是第三条「定期人工同步上游」。
而这条之所以要变,是因为另一件事在同期确定了:执行要放在服务端,做成 DAG 调度。这件事一旦定下来,同步的性质就变了——从「拿上游的新功能」变成「不断解决我和上游之间的语义冲突」。
「引入的目标已经达成」这种说法我没有写在当时的文档里,事后也不想补上去。当时的书面理由就是那条收益成本判断:产品形态与上游哲学分叉之后,同步收益趋近于零,冲突成本持续上升。
七、脱钩之后
值得一提的是后面发生了什么,因为它验证了这条判断的方向。
这套 fork 出来的前端后来又经历了两轮变化,最后整个 fork 被移除——前端改由自研重写,队列名从 canvas-graph-runs 换成了 canvas-flow-runs。
所以「脱钩」不等于「稳定了」。它只是把一件事确定下来:这个模块从此按自有代码演进,好坏都由自己承担。
而 07-23 那天真正保住的,是判断里的另一半——编排基建照抄旧引擎。队列、租约、恢复、计费台账这套模式一直用到了今天的画布运行上。脱钩的是前端血缘,不是自己的工程积累。
这两件事分开看,是那一天最值得记的部分:丢掉的是「别人的演进」,留下的是「自己验证过的东西」。判断的依据不是血缘远近,是有没有跑过。
■