152 lines
10 KiB
Markdown
152 lines
10 KiB
Markdown
# S3 跨机装配 · 落地件 — 2026-09-23
|
||||
|
|
|
|||
|
|
> **归口**:插件投放与分库线 · **第 6 棒 · 执行棒**(automation `a0558df7`)。
|
|||
|
|
> **唯一执行依据**:接续入口 `接续入口_插件投放与分库线_20260922.md` §0 最新行 + §5「⏭️ 本轮动作(第 6 棒)」;
|
|||
|
|
> 交接单 `交接单/插件投放与分库线-①共享只读包库与插件数据面.md §五 S3`(含 S3-E)。
|
|||
|
|
> **状态**:✅ 代码面已落地 + 106 真机 S3-E 全过 + 两机已部署重启 + **发现 1 条 D2 口径级缺陷(已取证、待拍板)**。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 一 一句话结论
|
|||
|
|
|
|||
|
|
S3「把装配动作修到**实例所在那台机**」已落地并真机验证:装配走 `applyPlugins` 一条腿,
|
|||
|
|
Manager 不再在本机跑 pnpm;抽出的公用模块在两台机跑**同一份** `applyProfileChanges`。
|
|||
|
|
S3-E ①–⑦ **全过**,47 的 `users/` 下该用户**零包实体副本**(D2 主判据)。
|
|||
|
|
|
|||
|
|
⚠️ 但真机取证**另抓出一条口径级缺陷**:D-h 拍板的 `file:` 协议在 pnpm 9 下会把插件包
|
|||
|
|
**整份复制**进每个用户的 `.pnpm/`(实测 6.5 M/用户/包),与 D2「实体一份、链接多份」相悖。
|
|||
|
|
已实测出可替换方案(`link:` 协议 ⇒ 4 KB 纯软链、且抗 reconcile),**因涉及已拍板项 D-h,交用户拍板**(见 §六)。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 二 落地清单(逐条对 §5 本轮动作)
|
|||
|
|
|
|||
|
|
| # | 要求 | 落点 | 状态 |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| 1 | `POST /plugins/apply`(装配入口) | `src/worker/agent.ts`(同族于 `/restart-probe`,⛔ 不新增入站口) | ✅ |
|
|||
|
|
| 2 | `remote-spawner.applyPlugins`(跨机那条腿) | `src/supervisor/remote-spawner.ts`(按 `hostIdFor` 定位机器) | ✅ |
|
|||
|
|
| 3 | `mine/apply` 改为**只做清单裁决** | `src/web/routes/business-plugins.ts`(⛔ 不再本机 `runPnpmAs`) | ✅ |
|
|||
|
|
| 4 | 抽公用模块(⛔ 不许两份判据) | **新建** `src/supervisor/plugin-assembly.ts`(约 400 行) | ✅ |
|
|||
|
|
|
|||
|
|
### 附带修正(真机取证暴露,属"只做被明确要求的事"边界外的必要修复)
|
|||
|
|
|
|||
|
|
| 缺陷 | 性质 | 处置 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `pnpm install --ignore-workspace-root-check` | **pnpm 9 不认该 CLI 选项**(只当 config 项)⇒ `Unknown option` **直接失败**。旧代码一直裸写(2026-09-13 07:43:58 曾因此把平台带崩,当时只加 try/catch 兜住进程、⛔ 没修参数) | 改 `-w`(与 `scripts/ensure-biz-plugins.cjs` 既有正确姿势逐字一致)+ 回归测试钉住 |
|
|||
|
|
| 启用项**先落盘再 pnpm** | pnpm 失败时 `package.json` 留下"依赖已声明、实际未安装"的**半装状态** ⇒ 下次冷启动崩、用户却看到"装过了" | 改为**先解析全部包路径(失败即抛、此时 profile 未改)**;pnpm 失败**回滚** `package.json` |
|
|||
|
|
|
|||
|
|
### 关键实现点(三条最容易写错的)
|
|||
|
|
|
|||
|
|
1. **单腿装配**:worker agent 复用 `LocalSpawner`,因此**两台机跑的是同一段 `applyProfileChanges`** ——
|
|||
|
|
结构上消除"两份判据漂移"(这正是抽公用模块的目的)。
|
|||
|
|
2. **`RemoteSpawner.call()` 4xx 重试 bug(既有)**:4xx 的 `throw` 原先在 `try` 内被 `catch` 吞掉,
|
|||
|
|
循环跑满 4 次(注释写"重试没意义"但从未成立)。已用 `noRetry` 标记重抛修掉;新测试断言 `calls === 1`。
|
|||
|
|
3. **回滚靠"重发全停用清单"**:探活失败时不再用本机快照还原(跨机后快照**不代表远端磁盘**),
|
|||
|
|
改为重发 `enabled:false` 全清单 —— 落地方按全清单 reconcile,幂等且跨机重试安全。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 三 S3-E 真机取证(106 · `w-106` · 非 Manager 的 worker)
|
|||
|
|
|
|||
|
|
机位:106(`/opt/dshs-cluster` · worker `dshs-worker` active)。探针:`/root/probe-s3e-106.mjs`
|
|||
|
|
(**调真实编译产物** `lib/supervisor/plugin-assembly.js`,⛔ 不手写 pnpm 命令)。
|
|||
|
|
被测用户:`4092b965-2f68-4977-9989-68b3966f7df0`(uid 100002,实例 `host_id = w-106`)。
|
|||
|
|
被测包:`@dsh-local/storyforge` 0.1.0(在候选池 + 在共享层)。
|
|||
|
|
|
|||
|
|
| # | 判据 | 命令 / 读数 | 期望 | 实测 |
|
|||
|
|
|---|---|---|---|---|
|
|||
|
|
| ① | 装配 task → `success`、stage 无"跳过" | `applyProfileChanges` 返回 | `enabled` 含该包、无静默跳过 | ✅ `applyOk:true`,`enabled:["@dsh-local/storyforge"]` |
|
|||
|
|
| ② | `package.json` 的 `file:` 指向共享层 | 读 profile `package.json` | `file:/var/lib/dshs/bundled-plugins/…` | ✅ `file:/var/lib/dshs/bundled-plugins/_dsh-local_storyforge` |
|
|||
|
|
| ③ | `node_modules/<pkg>` 是软链 | `lstat().isSymbolicLink()` | true | ✅ true,指向 `.pnpm/@dsh-local+storyforge@file+…/` |
|
|||
|
|
| ④ | 实例内可见共享层(ro) | `nsenter -t <pid> -m -- ls /var/lib/dshs/bundled-plugins/` | 见到两个包 | ✅ 见 `_dsh-local_storyforge` `dsh-plugin-mcn-suite` |
|
|||
|
|
| ④b | 只读断言 | `nsenter … -- touch …/__probe` | 失败 | ✅ `Read-only file system` |
|
|||
|
|
| ④c | mountinfo | `grep bundled-plugins /proc/<pid>/mountinfo` | `ro,nosuid,nodev` | ✅ 完全匹配 |
|
|||
|
|
| ⑤ | 反证:不存在的包 ⇒ 报错可读、实例不崩 | 传 `dsh-plugin-nonexistent-probe` | 抛错带 code | ✅ `code:"plugin_bundle_missing"`,报错文案「共享层里没有该插件的包:…」 |
|
|||
|
|
| ⑥ | **47 的 `users/` 不新增包实体副本** | `find /var/lib/dshs/users/<id> -name '*storyforge*'` | 0 | ✅ **0(连软链都没有)**,该目录 `du` = 8.0K(空壳) |
|
|||
|
|
|
|||
|
|
**空/停用腿同样真机验证**:对同一用户跑 `enabled:false` ⇒ `disabled:["@dsh-local/storyforge"]`,
|
|||
|
|
bundles 回到基线 6 项;清理后该用户 profile 与测试前**逐项一致**(deps/bundles 复原、`storyforge` 残留 0、`node_modules` 回到 51M)。
|
|||
|
|
|
|||
|
|
**⛔ 用户面零残留**:测试期间的临时目录已全清;被测用户 profile 已复原。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 四 验证汇总(回归三件套)
|
|||
|
|
|
|||
|
|
| 项 | 命令 | 结果 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| 构建 | `npm run build` | **rc=0** |
|
|||
|
|
| 回归 | `npm test` | **439 tests / 437 pass / 0 fail / 2 skipped**(基线 426/424 ⇒ **+13**) |
|
|||
|
|
| 分层 | `node scripts/check-layering.mjs` | **✅ 无新增违规**(基线 5 条不变,`added: 0`) |
|
|||
|
|
|
|||
|
|
⚠️ `check-layering` 报 `未归类 1:src/platform-paths.ts` —— **非本棒引入**(既有文件),⛔ 未动。
|
|||
|
|
|
|||
|
|
### 部署(两机同批)
|
|||
|
|
|
|||
|
|
| 机 | 落点 | 备份 | 状态 |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| 47 | `/opt/dshs/lib`(md5 `plugin-assembly.js` = `134b5704…`) | `/opt/dsh/backups/lib/lib-pre-s3-20260923-075200.tgz` | `dshs` active;v15 两张台账表随 `restart dshs` **已落** |
|
|||
|
|
| 106 | `/opt/dshs-cluster/lib`(同 md5) | `/opt/dsh/backups/lib/lib-pre-s3-20260923-075419.tgz` | `dshs-worker` active |
|
|||
|
|
|
|||
|
|
两机 `plugin-assembly.js` **md5 逐字一致**;共享层内容已同步到 106(storyforge + mcn-suite + `.manifest.json`)。
|
|||
|
|
`relay-dialer` 实测已连到 `ops/w-106` ⇒ 跨机通路在线。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 五 🔴 真机抓出的口径级缺陷(**本棒最重要的发现**)
|
|||
|
|
|
|||
|
|
### 现象
|
|||
|
|
|
|||
|
|
S3-E ③ 只断言"`node_modules/<pkg>` 是软链" ⇒ **会假绿**。深挖实测(106,同一份共享层源):
|
|||
|
|
|
|||
|
|
| 协议 | `node_modules/<pkg>` | `.pnpm` 中间层 | 实测占用 |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| `file:`(**D-h 现拍板**) | 软链 ✓ | **整份实体复制**(116 文件、`links=1`、inode 与源不同) | **6.5 M** |
|
|||
|
|
| `link:` | **直接软链到共享层** | 无中间层 | **4.0 K** |
|
|||
|
|
|
|||
|
|
⇒ 现实现满足"① 共享层是唯一**来源**",但**不满足 D2 的"实体一份、链接多份"**:
|
|||
|
|
每个启用该插件的用户各占一份 6.5 M(`mcn-suite` 为 24 M 级 ⇒ 每用户 24 M)。
|
|||
|
|
|
|||
|
|
### 根因(已定位到 pnpm 行为)
|
|||
|
|
|
|||
|
|
pnpm 对 **`file:`(目录型依赖)** 走 `packageImportMethod` 的复制路径,⛔ 不硬链;
|
|||
|
|
对 registry tarball 依赖才硬链(同 profile 实测:普通依赖 `links=9`,storyforge 全部 `links=1`)。
|
|||
|
|
⇒ **不是代码写错,是 `file:` 协议的固有语义**。
|
|||
|
|
|
|||
|
|
### 已实测的替换方案(`link:`)
|
|||
|
|
|
|||
|
|
- **占用**:4.0 K(vs 6.5 M)—— 纯软链直达共享层;
|
|||
|
|
- **抗 reconcile**(D-h 选 `file:` 的**唯一理由**):实测 `link:` 在「再加一个依赖」与 `pnpm remove` 后
|
|||
|
|
**软链仍在** —— 因为它是由 `package.json` 声明的依赖,⛔ 不是手工软链(A1 的失败原因);
|
|||
|
|
- **符合规划棒原文**:`§3` 技术方向首选实现即「`node_modules` 走硬链/符号链接 ⇒ 实体一份、链接多份」。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 六 ⏭️ 待拍板(1 项 · 涉及已拍板项 D-h,⛔ 不替拍)
|
|||
|
|
|
|||
|
|
**问题**:装配协议是维持 `file:` 还是换 `link:`?
|
|||
|
|
|
|||
|
|
**候选 A —— 维持 `file:`(现状)**
|
|||
|
|
- 优点:零改动,S3-E ①–⑦ 已全过;`file:` 在旧 profile 里已有先例(交接单 §9.3 实测 5 条里 1 条已指共享池)。
|
|||
|
|
- 缺点:**每个启用用户各复制一份整包**(实测 storyforge 6.5 M/人,mcn-suite 24 M 量级)⇒
|
|||
|
|
与 D2「⛔ 不给每个用户复制一份」直接相悖;用户数一上来磁盘线性增长。
|
|||
|
|
|
|||
|
|
**候选 B —— 换 `link:` 协议**
|
|||
|
|
- 优点:实测占用 **4.0 K**(纯软链),真正兑现 D2「实体一份、链接多份」;抗 reconcile 已实测通过
|
|||
|
|
(加依赖 / remove 后软链仍在)⇒ **同时满足 D-h 当初选 `file:` 的全部理由(耐久性)**;
|
|||
|
|
与规划棒 §3 首选实现一致。
|
|||
|
|
- 缺点:需改公用模块 1 处 + 交接单 §五 等 **8 处**文档里的 `file:` 描述;
|
|||
|
|
存量用户 `file:` 写法要迁移(**可复用既有 snapshot/restore + 幂等重装机制**);
|
|||
|
|
⚠️ `link:` 是否被 dsh 侧 bundle 解析完全接受**尚未在实例内端到端验证**(本棒只验了 pnpm 层)。
|
|||
|
|
|
|||
|
|
> **本棒的处置**:⛔ 未擅自改。已按 A(现状)交付并验证通过;B 的证据(占用对比、抗 reconcile、inode 读数)已备齐,
|
|||
|
|
> 拍板即可落地。**倾向 B** —— 它是唯一同时满足 D2 与 D-h 耐久性的形态。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 七 本轮**未做**(⛔ 勿误解为已做)
|
|||
|
|
|
|||
|
|
- S5-a 兼容矩阵 · 迁移前结构备份(`pg_dump --schema-only`)
|
|||
|
|
- S6 门户三段式 UI(平台共享只读 / 已启用 / 已停用)
|
|||
|
|
- 跨节点内容分发(106 的共享层本棒是**手工同步**的,尚无平台侧分发通路)
|
|||
|
|
- ⛔ 未 merge / 未引入新依赖 / ⛔ 未 commit / 未 push
|