改了 src/ 不等于改了主进程:让依赖图决定要不要重启

落地通道的部署判断从“文件在哪个目录”换成“主进程到底加载了什么”,顺便把常驻作业从主进程的发布节奏里解了出来。
1. 上一版留下的粗糙边⌗
上一篇讲过,落地通道有两张表:验证计划决定合并候选要跑哪些检查,部署计划决定运行中的系统怎样才能用上它。但在这次改动之前,第二张表还是从第一张表派生出来的:
// 改动前的 deploymentPlan
const plan = verificationPlan(paths)
if (plan.level === 1) return { restart: false, ... } // 热层/文档
if (plan.level === 2) return { restart: true, ... } // UI
return { restart: true, reason: plan.reason } // 其余一律重启
第三级的意思是“有不属于热层、也不属于 UI 的路径”,所以它其实等于“仓库里任何别的文件”。验证这么分没有问题,未知路径就该从严检查。部署这么分就太宽了:仓库里不是每段代码都跑在主进程里。
举个虚构但很典型的例子:仓库里有一个独立运行的辅助客户端,它的代码放在 src/ 下的一个子目录,另外还有它自己的构建脚本和分发清单。改这几个文件,验证上理应走第三级,类型检查和受影响测试都要跑。可主进程从来不 import 这些文件,为它们发一次 release、重启主进程,结果只是多一次预演、多一次排空在飞的工作,再多一段试用期,运行中的代码一行都没变。
所以要回答的问题得换一个:重启是为了让主进程加载新代码,那就该看主进程会加载什么,文件放在哪个目录说明不了这一点。
2. 让打包器回答“主进程加载了什么”⌗
新增的 runtime-files.ts 只做一件事:拿到一份部署提交的检出,从主进程入口 src/start.ts 出发,用 Bun.build 带 metafile: true 解析一遍,取 metafile 里的 inputs 当作模块集合。
const result = await Bun.build({
entrypoints: [join(root, MAIN_RUNTIME_ENTRY)], root, target: 'bun',
packages: 'external', metafile: true,
plugins: [{ name: 'runtime-repository-modules', setup(build) {
build.onResolve({ filter: /^[^./]/ }, ({ path }) => {
const workspace = workspaces.get(packageName(path))
// 已安装的包由锁文件定版本;workspace 代码必须来自这个提交
return workspace ? { path: Bun.resolveSync(path, workspace) } : undefined
})
}}],
})
const modules = new Set(Object.keys(result.metafile.inputs).map(toRepoRelative))
里面有三个刻意的选择:
| 选择 | 原因 |
|---|---|
| 用打包器的解析结果,不自己写 import 扫描 | 静态 import、字面量动态 import('./x')、package.json 的 exports 都按运行时同一套规则解析,不用另外维护一个“差不多像”的解析器 |
packages: 'external' | 第三方依赖不进图。它们的版本由 bun.lock 决定,锁文件本身放在启动输入里(见下节) |
| workspace 包用插件拉回图内 | workspace 包虽然以包名引用,代码却在这个仓库里、跟着这个提交变化。把它当外部包,改动就会漏掉 |
整个过程只解析,不执行:不安装依赖,也不运行入口。注释里写的是 “without installing or executing its code”。
测试夹具把这些边界都钉住了:入口静态引用一个 workspace 包,动态 import('./core'),再引用一个未安装的第三方包;旁边放一个主进程不引用的 src/temp-host/main.ts。断言模块集合恰好是:
src/core.ts
src/start.ts
vendor/shared/index.ts
第三方包不在图里,独立客户端的文件也不在。workspace 包的 package.json 进了启动输入。
3. 图看不见的东西:启动输入清单⌗
模块图只覆盖“被 import 的源码”。主进程启动还依赖另外几类文件,它们不会出现在 metafile 里,所以单列一张清单:
| 输入 | 为什么图里没有 | 为什么必须重启 |
|---|---|---|
package.json、bun.lock、bun.lockb、bunfig.toml、.npmrc | 不是模块 | 决定装哪些依赖、以什么方式运行 |
workspace 各包的 package.json | 只是解析依据 | exports 一变,解析结果跟着变 |
tsconfig*.json | 编译配置 | 影响路径别名和转译行为 |
patches/ | 依赖补丁 | 改的是已安装包的内容 |
src/storage/pg-migrations/ | 迁移器用计算出来的路径动态 import,打包器追不到 | schema 迁移在主进程启动时执行 |
src/testing/remote/runner.sh | 是脚本,不是模块 | 某个模块在初始化时就把它读进内存 |
迁移那一行最能说明为什么需要这张清单。仓库里的迁移器是这样加载迁移文件的:
await import(join(PG_MIGRATIONS_DIR, migration.file))
路径在运行时才拼出来,任何静态分析都看不到。如果只看模块图,改一个迁移文件会被判成“与主进程无关”,而这恰恰是最不该漏掉的一类改动。所以这里用了前缀规则,没有去追求“图是完备的”这种幻想。
判断顺序于是成了一条短链,测试 names the first module graph hit 断言 reason 报出的是第一个命中的文件:
改动路径
│
├─ 命中模块图 → 重启 "<path>: main runtime module graph changed"
├─ 命中启动输入 → 重启 "<path>: main runtime startup/release input changed"
├─ 有 ui/ 路径 → 重启 (静态文件来自运行中的只读 release)
└─ 以上都不是 → 不重启,源码树更新即可
UI 那一支保留了上一篇的结论:WebUI 的静态产物由运行中的不可变 release 提供,验证再轻,它也得跟着 release 走。
4. 验证级别和部署决定从此互不推导⌗
这次最干净的一处变化是 deploymentPlan 的签名:
deploymentPlan(paths, runtime: MainRuntimeFiles)
它不再调用 verificationPlan。测试里有一组四个路径(独立客户端的两个源文件、它的构建脚本、一份分发清单),同时断言了两件事:
expect(verificationPlan(paths).level).toBe(3)
expect(deploymentPlan(paths, runtime)).toEqual({ restart: false, reason: /no main runtime file changed/ })
第三级验证,不重启。 这两件事以前绑在一起,原因只是它们恰好用了同一份路径分类。拆开以后各自表达各自的事实:要检查多少,取决于改动有多大可能出错;要不要重启,取决于运行中的进程会不会读到它。
反过来也一样。如果独立客户端里某个文件被主进程 import 了(夹具里的 src/temp-host/update-source.ts),它就在模块图里,照样触发重启,reason 会指出就是这个文件。“这个目录不重启”这种规则写不出来,也不需要写。
5. 用哪一份代码算图⌗
图必须从部署提交本身算出来,不能从源码检出算。源码检出是可变的,部署那一刻里面可能已经有别的提交,甚至有没提交的修改。
所以 Deployer 用 withWorktree 在部署目标上开一个临时 detached worktree,在里面跑 mainRuntimeFiles,跑完删掉。同一个目标的结果按 commit 缓存,一次部署里多次判断不会重复打包。
测试 uses the deployment commit graph rather than the mutable source checkout 专门模拟了这种污染:落地一个只改独立客户端的提交之后,往源码检出的 src/start.ts 里写一句 import './not-in-the-commit'。如果实现偷懒去读检出,要么解析失败,要么图被污染。实际结果是部署正常结束,没有重启,落地记录的 detail 写的是 “no main runtime file changed”。
6. 算不出图,不等于“保险起见重启”⌗
这里有个很诱人的默认:图算失败了,就当它改了主进程,重启一下总是安全的吧?
实现明确没这么做。mainRuntimeFiles 遇到无法解析的相对 import 会抛 main runtime module graph failed,部署随之中止。测试 a graph error aborts deployment without restarting or marking the landing running 断言了三件事:
| 断言 | 含义 |
|---|---|
p.restarts 为空 | 没有发起重启 |
落地记录的 deploy 状态仍是 pending | 没有假装部署完成 |
| 没有活跃部署记录 | 不会留下悬空的半成品 |
而且图恢复正常后,再调用一次 deployLane 就能走到 finished。
我认为“出错就重启”在这里是错的默认,理由有两条:
- 它让分类器无法证伪。 只要失败路径的结果和“改了核心”一样,图分析坏掉也没人会发现。部署照常进行,只是悄悄退回到第一节那种按路径猜的行为。抛错会让这条退化变成一条看得见的失败。
- 图算不出来,往往说明这个提交本身有问题。 主进程入口出发有一个相对 import 解析不了,主进程多半也起不来。拿它去重启,是把一个能在部署前确定性发现的问题推到预演阶段才暴露,甚至更晚。
这和“未知路径升到第三级验证”并不矛盾。那里说的是“不认识的东西从严检查”,前提是检查能跑起来。这里说的是“检查跑不起来,就别假装有结论”。
7. 顺带解开的第二个耦合:常驻作业⌗
源码树里还有几个常驻作业。它们以 bun run <entry> 的方式从源码检出启动,启动时把代码加载进内存,之后就不再读文件。落地通道会记住每个作业上次是在哪个提交上重启的,等它的 import 闭包变了,再按精确的 label 把它踢一下。
以前的规则是:只要检出里有主进程还没部署、又落在作业闭包里的改动,作业就等。用意是对的,作业不能跑到主进程前面,因为 schema 迁移要等主进程启动时才执行。问题在于条件太宽:只改了作业自己的代码,也要等到下一次主进程 release。现在主进程的 release 本身就可能被省掉(第 4 节),这类作业就可能一直等下去。
新规则把“等”的条件收窄成三项的交集:
未部署的改动 path 会让作业等待,当且仅当:
path ∈ 主进程运行时文件(模块图 ∪ 启动输入)
且 ( path ∈ 作业闭包 或 path ∈ {package.json, bun.lock} 或 path 是迁移文件 )
测试给出的决策表:
| 未部署的改动 | 作业自上次以来的改动 | 决定 |
|---|---|---|
| 只有作业入口 | 作业入口 | kick,不等主进程 |
| 作业与主进程共用的存储模块 + 作业入口 | — | wait |
| 一个迁移文件 | 作业入口 | wait,reason 里点名该迁移 |
| 只有主进程用的路由模块 + 作业入口 | 作业入口 | kick,路由不在作业闭包里 |
| 一份 skill 文档 | 作业入口 | kick |
| 主进程运行版本未知 / 无法 diff | — | wait |
迁移那一行用的还是第 3 节那个道理:作业的闭包里没有一句 import 指向迁移文件,但它在 schema 上依赖迁移,这个依赖是隐式的。代码里的注释只写了这一点:“Migrations are an implicit schema dependency, not a literal import in a job’s closure.”
刷新作业时,主进程的模块图在检出 HEAD 上只算一次,所有作业共用;图算失败就整体抛错,不踢任何作业(测试 computes the main runtime graph once at checkout head; graph failure does not kick jobs)。失败语义和部署那边一致。
8. 代价和没解决的事⌗
| 项 | 现状 |
|---|---|
| 每次部署多一次 worktree + 打包解析 | 换来的是可能省掉一次完整重启(预演、排空、试用期)。我手头没有耗时数据,只能说按结构推断这笔交换合算 |
| 启动输入清单是手写的 | 新出现一种“模块初始化时读文件”的写法,就得有人把它加进清单。runner.sh 那一项就是这样补上的,代码里留了一句 why 注释 |
| 计算路径的动态 import | 打包器追不到。目前已知的一处(迁移)靠前缀规则兜住,其他同类写法要靠清单来发现 |
| UI 仍然要重启 | 没变。要改,得先有独立于 release 的静态产物发布路径 |
整件事说穿了:“要不要重启”是一个关于运行中进程读了哪些字节的事实问题,以前拿路径分类去近似它,现在直接从部署提交里把答案算出来。算得出就照答案办,算不出就停下报错,不拿“重启总没错”当作答案。