feat(fs): W4-E4 fsproxy hang isolation, partial sweep, replace-with-verify, TIMEOUT retry
- adapters/fs: FsProxy + fsworker run each file op in a killable subprocess;
progress-stall watchdog (FS_HANG_TIMEOUT_MS) for copy/hash, total timeout for
quick ops; SIGTERM -> SIGKILL -> FsError('TIMEOUT'); stat/checkRoot timeouts
never read as missing
- copyWithVerify: onExisting conflict/skip/replace (hardlink backup + atomic
rename, old file to .umt-trash or deleted), sweep stale partials before write,
document why permissions/owner are not copied
- filesMatch/filesMatchSha256 throw FsError on read failure instead of
match=false; crossDeviceMove skips redundant re-hash after sha256 copy
- FsErrorCode TIMEOUT + fsErrorClass; organize executeRecord routes fs ops
through fsproxy, retries TIMEOUT/IO up to FS_TRANSFER_MAX_ATTEMPTS then opens
a decision without deleting the record; reconcile errors no longer reported
as EEXIST
- domain: subtitle .default marker (ORGANIZE_DEFAULT_SUBTITLE_LANG) and
findOtherVersions (same season/episode/part, video only) as upgrade
disposal candidates
- scripts/bench/fs-bench.ts and benchmark record in porting doc
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
36dc048e89
commit
2e2d56184c
21 files changed
+1970
-179
No files matched your search
@@ -140,3 +140,56 @@ MP 的 Rust 不是给 IO 用的,所以"Rust 提速转移"不构成我们要追
|
||||
5. **不复制权限/属主,尊重目标目录继承 ACL**;以及字幕 `.default` 标记。
|
||||
|
||||
MP 做法中明确**不照抄**的:删源失败仍报成功;move 遇 EXDEV 静默降级;`os.replace` 覆盖式提交;hardlink 先临时名再 move(破坏 link 的 no-clobber 原子性);随行文件永远覆盖。
|
||||
|
||||
## 7. W4-E4 落地状态(2026-09-27)
|
||||
|
||||
按 §4 对照表逐条:
|
||||
|
||||
| # | 条目 | 状态 | 落点 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | onExisting 接线 + replace 分支 | **已做**。`ImportRequest.onExisting/grantReplace`(缺省 `ORGANIZE_ON_EXISTING=conflict`)只透传给域层规划;执行端按冻结计划的 `action` 传 `onExisting`(replace→`replace`,其余→`conflict`)。`copyWithVerify` 的 replace:新文件写临时 → 校验 → 旧文件硬链接留底 → `rename` 原子替换(无空窗)→ 留底按 `FS_REPLACE_OLD` 移到 `<库根>/.umt-trash/replaced-*/`(缺省)或删除;处置失败留原目录并建待处理。只替换普通文件,目录/软链(含失效软链)仍按冲突。move + replace = 替换式复制后删源,删源失败 attention | `adapters/fs/copy.ts replaceCommit`、`organize.ts transferFile` |
|
||||
| 5 | latest 旧版本判定口径 | **已做**(只作处置候选)。`findOtherVersions`:同目录 + 同季 + 同集 + 同 Part + 仅视频扩展名;升级入库的"旧版本待处置"决策证据带 `otherVersionFiles`,不删 | `domain/organize/versions.ts`、`finalizeRecord` |
|
||||
| 8 | 写前清 partial | **已做**。`copyWithVerify` 开始前清目标目录 >24h 的 `.uhd-partial`(`sweepPartialsOlderThanMs`,null 关闭);`executeRecord` 对每个目标目录再经 fsproxy 清一次(覆盖同卷 move)。阈值 `FS_PARTIAL_MAX_AGE_MS`,失败吞掉 | `copy.ts`、`organize.ts` |
|
||||
| 10 | 对账比对异常不冒充 EEXIST | **已做**。`filesMatchSha256`/`filesMatch` 读失败抛 FsError;`transferFile` 对账阶段比对失败按 IO/TIMEOUT 失败并带 `evidence.phase=reconcile`;源/目标 stat 失败按 EACCES/ROOT_OFFLINE/TIMEOUT/IO 分类,只有 missing 才是 SOURCE_MISSING;跨卷 failed 带首个失败步骤的错误码 | `copy.ts`、`organize.ts statFailure` |
|
||||
| 12 | 不复制权限/属主 | **确认现状**:只 `utimes`。`copyWithVerify` 注释写明理由(继承 ACL),防后人补 chmod/chown | `copy.ts` |
|
||||
| 13/14 | 进度心跳 + 挂死隔离 | **已做**。`FsProxy` + `fsworker.ts`:每个文件操作一个执行子进程;数据类按进度停滞 `FS_HANG_TIMEOUT_MS`(120 s)判挂死,快操作按 `FS_QUICK_TIMEOUT_MS`(30 s);超时 SIGTERM → `FS_KILL_GRACE_MS` → SIGKILL → `FsError('TIMEOUT')`;心跳间隔 `FS_PROGRESS_INTERVAL_MS`。`checkRoot` 超时 = `offline(timeout)`,`statSafe` 超时 = `error(TIMEOUT)`,都不判缺失。`executeRecord` 的根检查、对账 stat/比对、copy/move/跨卷全部走 fsproxy。测试:worker 测试开关阻塞 JS 线程 + 屏蔽 SIGTERM → SIGKILL;FIFO 上 open 真实阻塞 → SIGTERM 回收 | `adapters/fs/fsproxy.ts`、`fsworker.ts`、`fsops.ts` |
|
||||
| 16 | 字幕 `.default` | **已做**。`sidecarLanguageSuffix(tags, defaultLang)` / `OrganizeInput.defaultSubtitleLang`;服务端取 `ORGANIZE_DEFAULT_SUBTITLE_LANG`(不设不加) | `domain/organize/sidecars.ts` |
|
||||
| 19 | TIMEOUT 错误码 | **已做**。`FsErrorCode` 加 `TIMEOUT`(ETIMEDOUT 也映射);`fsErrorClass`:IO/TIMEOUT/ROOT_OFFLINE/SOURCE_CHANGED=retry,EACCES/ENOSPC/EXDEV=attention,EEXIST/VERIFY_FAILED/ENOENT=manual。任务层:retry 类失败且账本尝试次数 < `FS_TRANSFER_MAX_ATTEMPTS`(3)→ 结果不入账本、文件保持 running,整轮结束后抛错交引擎退避重试;到上限 → 按失败入账 + 待处理 `fs_retry_exhausted:<opId>`,记录与下载保留、不重下(Slot 走 verify_failed → attention,不触发重新获取) | `errors.ts`、`organize.ts executeRecord` |
|
||||
| 4 | 失效软链目标 | 未做(replace 对软链一律冲突,不自动覆盖) | W5 |
|
||||
| 11 | hardlink | 留 W5 | — |
|
||||
| 17 | 空目录上溯清理 | 留 W5(边界规则见 §3.3/§6) | — |
|
||||
| 18 | 蓝光原盘目录 | 留 W5 | — |
|
||||
|
||||
新增配置(`apps/server/src/config.ts`):`FS_PROXY_MODE`、`FS_HANG_TIMEOUT_MS`、`FS_QUICK_TIMEOUT_MS`、`FS_PROGRESS_INTERVAL_MS`、`FS_KILL_GRACE_MS`、`FS_PARTIAL_MAX_AGE_MS`、`FS_TRANSFER_MAX_ATTEMPTS`、`FS_REPLACE_OLD`、`ORGANIZE_ON_EXISTING`、`ORGANIZE_DEFAULT_SUBTITLE_LANG`。
|
||||
|
||||
遗留:
|
||||
|
||||
- **编译二进制**(`deno compile`)里 `Deno.execPath()` 不是 deno CLI,`FS_PROXY_MODE=auto` 回退 in-process 看门狗(能按时报 TIMEOUT、不阻塞任务,但卡死的系统调用线程无法回收)。要在镜像里也用子进程:`main.ts` 加 `--role fsworker` 调 `fsWorkerMain()`,compile 权限放开对自身的 `--allow-run`,再以 `workerCommand` 构造 FsProxy。
|
||||
- 规划侧(`planImport` 的源/目标 stat 与同尺寸 sha256)、`reconcile.ts`、文件管理(W5-A)仍直接调进程内 fs,未走 fsproxy。
|
||||
- 替换进 `.umt-trash` 的旧文件未登记 `trash_item`(W5-A 的回收站表),到期清理需 W5-A 接上。
|
||||
- 根挂死后没有"标 suspect、停止向该根派发"的根级熔断,目前靠根检查超时 → 整轮重试。
|
||||
|
||||
### 基准记录
|
||||
|
||||
`deno run -A scripts/bench/fs-bench.ts --base <被测盘> --other <跨卷目标>`,1 GiB 随机数据、10 万个 1 字节文件(100 目录 × 1000)。本机:96 核、125 GB 内存,本地盘 nvme(ext4,`/opt`),`/dev/shm` 为 tmpfs。数据刚写完即测,都在页缓存里(热缓存,代表 CPU/拷贝路径上限而非磁盘冷读)。
|
||||
|
||||
| 场景 | base=nvme → other=/dev/shm | base=/dev/shm → other=nvme |
|
||||
| --- | --- | --- |
|
||||
| 全量哈希 1 GiB:`sha256sum` 子进程 | 2716 ms / 377 MB/s | 2740 ms / 374 MB/s |
|
||||
| 全量哈希 1 GiB:进程内流式 | 3411 ms / 300 MB/s | 3265 ms / 314 MB/s |
|
||||
| 全量哈希 1 GiB:fsproxy 子进程(流式 + 心跳) | 3206 ms / 319 MB/s | 3175 ms / 323 MB/s |
|
||||
| 跨卷 move 1 GiB,hash=size,进程内 | 1775 ms / 577 MB/s | 3008 ms / 340 MB/s |
|
||||
| 跨卷 move 1 GiB,hash=size,fsproxy | 2192 ms / 467 MB/s(心跳 2 次) | 2860 ms / 358 MB/s |
|
||||
| 跨卷 move 1 GiB,hash=sha256,进程内 | 7382 ms / 139 MB/s | 7971 ms / 128 MB/s |
|
||||
| 跨卷 move 1 GiB,hash=sha256,fsproxy | 7275 ms / 141 MB/s(心跳 8 次) | 8042 ms / 127 MB/s |
|
||||
| 对照:`cp` 跨卷 | 578 ms / 1770 MB/s | 633 ms / 1618 MB/s |
|
||||
| 扫描 10 万文件(`scanIncremental`,每批 2000,50 批) | 15.1 s | 17.4 s |
|
||||
| 扫描 10 万文件(单批) | 11.7 s | 15.7 s |
|
||||
|
||||
读法:
|
||||
|
||||
- 这台机器的 SHA-256 单核约 300–380 MB/s(无 SHA-NI 加速迹象),`sha256sum` 只比进程内流式快约 20%;fsproxy 子进程里流式哈希与进程内持平,**挂死隔离几乎不损失吞吐**(子进程启动约 50 ms,1 GiB 级文件可忽略)。
|
||||
- hash=sha256 的跨卷 move 瓶颈是哈希:copy 边写边算 + 回读临时文件再算一遍 ≈ 2× 哈希时间。本次顺带去掉了 `crossDeviceMove` verify 步骤对两边的重复全量哈希(copy 已 sha256 核验时 verify 只比大小),从 103 MB/s 提到约 140 MB/s。
|
||||
- hash=size 的 Node 流复制约为 `cp` 的 1/3(`cp` 用 copy_file_range),对 HDD/千兆网络盘不是瓶颈;若将来要贴近 `cp`,可在 worker 里改走 `copy_file_range`/`cp --reflink=auto`,仍由心跳看门狗兜底。
|
||||
- 10 万文件扫描 12–17 s,远低于 §5 建议的"单根全量 < 5 min";分批游标的额外开销约 20%。网络盘上 stat 延迟高一两个数量级,需实测。
|
||||
- §5 的 B1(50 GB + API 延迟)、B2(并发 1/2/4)、B3(FUSE 冻结注入)仍未跑;B3 的判定逻辑已由 FIFO/阻塞测试覆盖,真实 FUSE 冻结待有环境再测。
|
||||
Reference in new issue
Block a user