- 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]>
26 KiB
MoviePilot v3 的 Rust 部分与整理/转移:核查与借鉴清单
上游:
/opt/moviepilot-plus/upstream/MoviePilot,提交e6820a8(单提交浅克隆,git log只有 1 条,无法用git log -S/--grep追溯动机,动机证据只能取自仓内文档)。 只读核查,未运行上游。行为对照,不复制源码(GPLv3,见license-and-provenance.md)。转移主流程的完整对照见moviepilot-transfer.md,本文只回答"Rust 在转移里干了什么"以及由此引出的差异。
0. 结论先说
MP v3 的文件复制/移动/链接没有下沉到 Rust,全部是 Python。 "整理模块走 Rust"这个前提不成立,需要纠正:
- Rust 扩展是独立 PyPI 包
moviepilot-rust~=0.3.3(pyproject.toml:49,PyO3 + maturin,仓外MoviePilot-Rust仓库构建),本机只有编译好的moviepilot_rust.abi3.so(10 MB),没有 Rust 源码、没有.pyi。 - 包 README 自述的模块:
filter/ indexer/ metainfo/ metamusic/ rss/ support/,没有 fs/io 模块。 .venv/.../native_internal.cpython-314-*.so属于 mypy 1.18.2(mypy-*.dist-info/RECORD登记,符号是PyInit_native_internal+ mypyc 运行时字符串),与 MP 业务无关。- 转移链路上唯一经过 Rust 的是文件名识别:
MetaInfoPath→parse_metainfo_path_fast。它在转移里用于目标路径规划和latest覆盖模式下"找同季同集的其它版本"。 - MP 为转移做的"原生级"加固其实是另一件事:
fsproxy常驻子进程(app/adapters/system/fsproxy.py+fsworker.py),目的是让挂死在 FUSE/网络挂载上的系统调用能被 SIGKILL 回收。这是本文最值得借鉴的部分。
1. Rust 导出函数清单
来源:.venv/bin/python -c "import moviepilot_rust; dir(...)" 实测 + app/adapters/system/rust.py 包装层。参数和返回结构按 Python 调用侧反推(推断,无 .pyi)。
| 导出函数 | Python 包装(rust.py) | 参数 | 返回 | 业务 | 与转移的关系 |
|---|---|---|---|---|---|
is_available() |
is_available/status |
— | bool | 探活 | 无 |
parse_metainfo_fast |
parse_metainfo |
title, subtitle?, options(dict,含自定义识别词与缓存键) | dict / None(None 表示回退 Python) | 种子标题 → 影视元信息 | 间接(识别阶段) |
parse_metainfo_path_fast |
parse_metainfo_path |
path, options | dict / None | 文件路径 → 季/集/Part/年份等 | 直接:MetaInfoPath 用于转移规划、__delete_version_files 判同季同集同 Part |
find_metainfo_fast |
find_metainfo |
title | {title, metainfo},metainfo 含 tmdbid/doubanid/type/begin_season 等 |
标题里的 {[tmdbid=…]} 类内嵌 ID |
间接(识别阶段) |
parse_metamusic_fast |
parse_metamusic(getattr,可缺) |
标题等 | dict | 音乐元信息 | 间接:音乐 size 覆盖模式用 audio_quality_score |
parse_filter_rule_fast |
parse_filter_rule |
expression | list(AST) | 过滤规则解析 | 无(订阅/搜索) |
filter_torrents_fast / _with_trace_fast |
filter_torrents |
规则 + 种子快照 | 命中序号列表(+trace) | 资源过滤 | 无 |
parse_indexer_torrents_fast |
parse_indexer_torrents |
HTML + 选择器配置 | list[dict] | 站点列表页解析 | 无 |
parse_indexer_subtitles_fast |
parse_indexer_subtitles |
HTML + 配置 | list[dict] | 站点字幕页解析 | 无 |
parse_rss_items_fast |
parse_rss_items |
xml, max_items | list[dict] / None | RSS/Atom 流式解析 | 无 |
jieba_cut |
app/foundation/text.py |
text, hmm, cut_all | list[str] | 中文分词 | 无 |
zhconv_fast |
app/foundation/text.py |
text, target | str | 繁简转换 | 间接(标题归一) |
包装层的通用语义(rust.py):开关 RUST_ACCEL 可关;V3t(free-threaded)下 is_required() 为真、不许关;Rust 返回 None 或抛非 panic 异常 → 回退 Python 实现,panic 类异常单独处理(_raise_non_rust_panic)。即 Rust 只是"同语义的快路径",Python fallback 是兼容基线。
2. Rust 下沉的动机(证据)
git 历史不可用。仓内文档给出的证据:
docs/v2-to-v3-overview.md§5.6:Rust "主要加速:影视和音乐标题解析、站点索引结果解析、RSS/Atom 解析、资源过滤和表达式处理、中文转换等高频文本操作"。列表里没有文件操作。- 包 README:"业务模块只接收和返回纯 Rust 类型…核心解析器可以独立单测,并可在转换完成后释放 Python GIL"。
docs/v3t-runtime-governance.md§6.2:应用识别热点三组中位耗时 Python fallback 0.0688s / V3+Rust 0.0353s / V3t+Rust 0.0355s,Rust 约 0.51x;"三组业务结果校验和一致"(强调语义等价)。§6.1:32 线程直接调用 Rust ABI 探针 V3 40.8k ops/s → V3t 61.6k ops/s。
判断:动机是 CPU 密集文本解析的吞吐 + 释放 GIL 以配合 V3t 多线程,并以"与 Python fallback 结果一致"为硬约束。文件 IO 本身在 CPython 里 read/write 已释放 GIL,瓶颈在磁盘/挂载,下沉 Rust 无收益,所以 MP 没做。它为 IO 做的是故障隔离(fsproxy),不是提速。
3. Python 侧保留的转移逻辑(即 MP 真正的转移语义)
以下全部是 Python,行号对 e6820a8。
3.1 本地存储 storages/local.py
- 临时文件与原子提交(
_write_atomically278–322):写到目标同目录的隐藏临时名.<name>.<pid>.mp-partial,完成后os.replace覆盖到目标;失败路径 finally 删临时文件。注释点明:临时文件必须同目录,否则 replace 退化为拷贝;带 PID 防多进程互踩。 - 残留清理(
_cleanup_stale_partials257–276):每次写入前只清本目标目录里 mtime 超过 24h 的*.mp-partial;不做全库扫描(网络挂载上代价不可接受)。 - move(416–446):先
os.replace,EXDEV 等 OSError → 降级为"原子复制 + 删源";删源失败只记 warn,返回成功。src == dest直接成功。 - 权限(
_copy_with_target_permissions324–340 及 fsworker_copy):只复制内容和 atime/mtime,不复制权限/属主,理由是目标目录的默认权限和继承 ACL 是媒体库访问策略,用源权限覆盖会清掉继承 ACL。 - 进度:只在"源、目标都是网络盘且同盘"时向 UI 回调(
__should_show_progress395–403),但 worker 内部始终每秒上报一次作为心跳。 - 取消:父进程在每次收到进度时检查取消标记,要取消直接杀 worker。
- hardlink(
host.py:500SystemUtils.link):先 link 到dest + ".mp"临时名,再shutil.move到最终名。softlink:直接symlink_to,无临时名。
3.2 fsproxy / fsworker(挂死隔离)
- worker 是只依赖标准库、按文件路径直接执行的常驻子进程(毫秒级启动),stdin/stdout 逐行 JSON。
- 快操作(stat/exists/listdir/rename/unlink/rmtree/count_entries)默认 30s 超时;复制按"两次进度心跳间隔"判挂死,默认 120s(
FS_PROXY_STALL_TIMEOUT),大文件复制几小时不会误杀。 - 超时 → SIGKILL worker → 抛
FileSystemTimeout(OSError),复用上层已有的 OSError 退避/登记重试。下一次请求自动拉起新 worker。 exists用os.stat而非os.path.exists:挂载抖动不能被误判成"文件不存在"。- 放行白名单按"被强杀后的副作用"划分:只读、同设备 rename、unlink 原子;rmtree 非原子但后果轻于"写一半"。跨设备 rename 在 worker 里显式拒绝(errno 18)。
- 请求严格串行(一把锁),即同一时刻只有一个 fs 操作在代理里。
3.3 TransHandler 与链路(transhandler.py、chain/)
- 覆盖模式(
__resolve_overwrite494–634):always/latest/never/size;另有插件事件可直接裁决覆盖并改写 source/target size。- 随行文件(字幕、外挂音轨)永远覆盖,不走模式判断。
- 目标查询失败(
StorageQueryError)→ 跳过并报失败,"避免误覆盖"。 - 目标是指向不存在文件的失效软链 → 视为可覆盖。
size:目标比源小才覆盖;音乐改比audio_quality_score。latest:覆盖本文件,并删除同目录同季同集同 Part 的其它视频版本(只删RMT_MEDIAEXT视频扩展名,issue 5449/5862)。never/不覆盖:返回overwrite_skipped=True,结算时若同路径此前已成功入库,不把历史改写为失败(settlement.py _is_overwrite_declined)。
- 步骤账本(680–1000):每个副作用是一个持久步骤(
ensure_target_directory/cleanup_previous_destination/discover_version_targets/ 逐个版本删除 /delete_overwrite_target/materialize_target/delete_move_source),每步配一个 observe 函数用于中断后判定 APPLIED / NOT_APPLIED / CONFLICT / UNKNOWN;证据不足(UNKNOWN)时禁止自动重放。- copy 的已应用判定用
filecmp.cmp(shallow=False)全量比对;link 用samefile;softlink 比resolve();move 看"源无目标有"。 - 跨存储 move 被拆成 materialize(copy) + delete_move_source 两步。
- copy 的已应用判定用
- 随行字幕改名(
__rename_subtitles1614):按正则识别繁中/简中/日/英 →.zh-tw/.chi.zh-cn/.ja/.eng,与DEFAULT_SUB设置一致时加.default。繁中先于简中判定。 - 空目录清理(
chain/storage.py delete_media_file161–222):向上逐级删除"不含媒体文件"的目录;一级以内路径禁止删除;遇到已配置的下载目录/媒体库目录的祖先即停;蓝光原盘目录整体删除。move 全部成功后才触发,且挂载盘可配置关闭(MountedLocalDiskDeleteEmptyDirs,默认开;关闭时对本地源探测是否网络/FUSE 盘,结果按目录缓存)。 - 扫描:
SystemUtils.list_files用os.scandir递归、不跟随符号链接、按扩展名正则和最小体积过滤,单条目 OSError 跳过。没有增量/游标。目录计数在 worker 内一次性os.walk并设上限 10000,避免上万次 IPC 往返。
4. 对照我们的实现
我们的文件:packages/adapters/src/fs/{copy,move,scan,stat,errors}.ts(任务里提到的 ports.ts 不存在,错误分类在 errors.ts)、packages/domain/src/organize/{plan,sidecars}.ts、apps/server/src/modules/acquisition-organize/services/organize.ts。
| # | MP 语义 | 我们现状 | 判定 | 建议 |
|---|---|---|---|---|
| 1 | 覆盖模式 always/latest/never/size + 插件裁决 | 域层 ExistingPolicy = skip / replace_with_verify / conflict,但服务端从不传 onExisting,恒为 conflict;执行端 copyWithVerify 遇已存在一律 EEXIST;没有 replace 执行分支 |
不同 + 部分缺失 | 保持"默认冲突转待处理"(比 MP 默认更保守,合理)。W5 待办:把 skip/replace_with_verify 接到设置与执行端;replace 实现为"新文件写临时名 → 校验 → 旧目标先改名留底 → 提交 → 删留底",不做无备份覆盖 |
| 2 | 随行文件永远覆盖 | 随行跟随主文件 action(主 skip/hold 则随行同样),目标存在走同一冲突逻辑 | 不同 | 保持不同:随行覆盖会静默替换用户手放的字幕。可加一条:主文件是 replace 时随行也 replace |
| 3 | 目标查询失败 → 跳过,防误覆盖 | lstat 非 ENOENT 抛 FsError;statSafe 区分 missing/其它 |
相同 | — |
| 4 | 失效软链目标视为可覆盖 | 未处理,lstat 存在即 EEXIST |
缺失 | W5:目标是软链且 stat ENOENT 时,在冲突证据里标 dangling_symlink,由 replace_with_verify 处理,不自动覆盖 |
| 5 | latest 删同季同集同 Part 旧版本,只删视频扩展名 |
升级时旧资产保留,只开"旧版本待处置"决策 | 不同 | 保持不同(我们有资产模型和保留策略,删旧版应走处置流程)。但照抄判定口径:同季+同集+同 Part+仅视频扩展名,作为处置候选的筛选条件 |
| 6 | 临时文件同目录、隐藏名、带 PID | partialPathFor:同目录 .<name>.<pid>.<rand>.uhd-partial |
相同(更强,加随机) | — |
| 7 | 原子提交 os.replace(会覆盖) |
commitNoClobber:link(tmp,dst) 原子失败于已存在,不支持时退回 lstat+rename;fsync 文件和目录;提交前比源 size+mtime 防源变更 |
不同(更严) | 保持 |
| 8 | 写前顺带清本目录 24h 以上 partial | sweepStalePartials 已实现但没有调用方 |
缺失 | 照抄:copyWithVerify 开始前对目标目录调用一次,阈值 24h,失败吞掉 |
| 9 | move 先 rename,EXDEV 静默降级为复制+删源;删源失败仍报成功 | moveSafe 用 link+unlink(no-clobber,支持中断续删);EXDEV 显式返回,需 allowCrossVolumeMove;跨卷三步 copy→verify→delete_source,删源失败 = attention + 决策 |
不同(更严) | 保持;MP 的"删源失败报成功"不照抄 |
| 10 | 步骤账本 + observe 判定 APPLIED/NOT_APPLIED/CONFLICT/UNKNOWN,UNKNOWN 禁止重放 | asset_operation + withAttemptToken;transferFile 开头对账:源无目标有且大小对→成功;两边都在→sha256 比对 |
大体相同 | 照抄一点:把"对账无法判定"显式建成 unknown 状态进待处理,而不是落到 failed(现在 filesMatchSha256 抛错被 catch 成 match:false → 报 EEXIST,错误分类不准) |
| 11 | hardlink:先链到临时名再改名;softlink | 契约有 OrganizationAction.hardlink 和 LocationKind.hardlink,执行端只有 copy/move |
缺失 | W5:hardlink 直接 link(src,dst)(本身 no-clobber、原子),EEXIST 时比 ino/dev 判"已完成";不需要 MP 的临时名 + move(那一步把原子 link 变成了可覆盖)。softlink 暂不做 |
| 12 | 只拷内容+时间戳,不拷权限/属主,尊重目标目录继承 ACL | 只 utimes,新文件权限由 umask/目录决定 |
相同 | 在 copy.ts 注释里写明"故意不拷权限"的理由,防后人补上 |
| 13 | 进度:worker 每秒心跳,UI 仅网络盘显示;父进程借心跳检查取消 | onProgress 每块回调但无调用方;取消走 AbortSignal 贯穿 pipeline |
部分缺失 | W5:organize 接 onProgress,按 ≥1s 节流写 organization_file_result 进度/事件;同时作为卡死检测的心跳(见 #14) |
| 14 | fsproxy:挂死系统调用放进可 SIGKILL 的子进程,快操作 30s、复制按 120s 无进度判挂死,转成普通 OSError | 只有库根哨兵 checkRoot(事前检查);Deno 的 fs 调用挂死时占住 libuv/tokio 阻塞线程池,无法取消也无法回收,AbortSignal 对已发出的 syscall 无效 |
缺失(最大差距) | W5 首要:给 fs 操作加"看门狗"——复制按进度停滞 N 秒判挂死、stat/readdir 设超时;超时后标记库根 suspect、停止向该根派发、进 ROOT_OFFLINE 待处理。彻底回收需把大文件复制放子进程(cp --reflink=auto 或自带小 worker),超时 kill。见 §5 基准 3 |
| 15 | exists 用 stat,抖动不等于不存在 | statSafe 分 ok/missing/其它;toFsError 只把 ENOENT 当不存在 |
相同 | — |
| 16 | 字幕语言后缀 .zh-tw/.chi.zh-cn/.ja/.eng,繁先于简,默认字幕 .default |
sidecarLanguageSuffix 同样的后缀、繁先于简,另加 .ko;无 .default |
大体相同 | 照抄 .default 标记(设置项 defaultSubtitleLang),Jellyfin/Plex 都认 |
| 17 | move 成功后向上清空目录:不删一级路径、不越过下载/库根、有媒体文件即停、挂载盘可关 | 无任何空目录清理 | 缺失 | W5,照抄边界规则;额外要求:仅在该记录全部成功时、只清本次源路径链上的目录、网络/FUSE 源默认关;删目录用 rmdir(非递归,天然只删空目录),而不是 MP 的"判定无媒体再递归删" |
| 18 | 蓝光原盘目录整体搬/删 | 未见 BDMV 专门处理 | 缺失 | W5,另立条目(见 moviepilot-transfer.md) |
| 19 | 错误分类:所有 IO 故障归 OSError,超时也伪装成 OSError 复用重试 | FsErrorCode:EXDEV/EEXIST/ENOENT/EACCES/ENOSPC/VERIFY_FAILED/SOURCE_CHANGED/ROOT_OFFLINE/ABORTED/IO |
我们更细 | 补一个 TIMEOUT(挂死),重试策略:TIMEOUT/IO 可退避重试,EACCES/ENOSPC 直接进待处理不重试,EEXIST/VERIFY_FAILED 需人工 |
| 20 | 扫描:scandir 递归、不跟软链、按扩展/体积过滤、无增量;计数在 worker 内一次性完成并设上限 | scanIncremental:游标分页、按 size+mtime 标 new/changed/unchanged、统计 stat/dir 数、错误收集 |
我们更强 | 已有每批 lstat 上限 2000、不跟随软链(lstat);只需照抄"整体超时"(见 #14) |
| 21 | 校验:copy 已应用判定用全量逐字节比较 | size/sample/sha256 可配,sha256 走 sha256sum 子进程;跨卷已存在目标强制 sha256 |
我们更强 | — |
5. 性能:Deno 单线程下我们够不够
MP 的 Rust 不是给 IO 用的,所以"Rust 提速转移"不构成我们要追的指标。IO 这一侧,MP 用的就是 Python 分块 read/write(chunk 可配)+ 子进程代理,并不比我们快。我们的做法:
- 复制:Node 流
pipeline,1 MiB 块,read/write 在 libuv 线程池,事件循环只做每块一次 Transform 回调(进度 + 可选增量 sha256)。 - 全量 sha256:
sha256sum子进程,完全不占事件循环;不可用时回退进程内流式。 - 组织执行在
ioworker 组,与 agent 组分开;有 loop lag probe。
潜在瓶颈只有两处:(a) hash: "sha256" 时 Transform 里的增量 hasher.update 在主线程,1 GB 约 1000 次、每次 1 MiB,单核 SHA-256 约 1–2 GB/s,会在大文件复制期间持续占用事件循环;(b) libuv 默认线程池 4 个,多个并发复制加上 stat/readdir 时,挂死的挂载会把线程池占满,连其它根的 fs 调用也会排队(这是 #14 的性能面)。
需要基准验证的 3 个场景与建议阈值:
| # | 场景 | 测量 | 建议阈值 |
|---|---|---|---|
| B1 | 同机不同卷复制 1 个 50 GB 文件,hash=sha256,同时跑 API 请求 |
吞吐 MB/s;loop lag p99;API p95 | 吞吐 ≥ cp 同条件的 85%;loop lag p99 < 50 ms;API p95 不超过空载 1.25x(同 MP 的 1.25x 门禁)。不达标则把增量哈希挪到 sha256sum 读 tmp 的一次性子进程(已有路径),Transform 里只计数 |
| B2 | 一季 24 集 × 2–5 GB,io worker 并发 1/2/4 | 总时长;每文件 mbps 分布;线程池排队(stat 延迟) | 并发 2 相对 1 的总时长下降 ≥ 30% 才值得开;任一并发下 stat p99 < 200 ms;HDD 目标盘默认并发 1 |
| B3 | 挂死注入:FUSE 挂载(或 dm-delay/SIGSTOP 的 sshfs)在复制中途冻结 |
从冻结到检测到的时间;其它根上的 organize/扫描是否被拖住;恢复后是否留下最终名半截文件 | 检测时间 ≤ 进度停滞阈值 + 10 s(阈值建议 120 s,同 MP);其它根操作 p99 不受影响(否则必须引入子进程复制);目标目录永不出现最终名半成品,partial 在 24 h 内被 #8 清走 |
扫描增量(scanIncremental)在 10 万文件、网络盘上的 stat 速率可作为 B2 的附带观测:建议单根全量扫描 < 5 min,超过则启用游标分片 + 限速。
6. 值得照抄的业务语义(按优先级)
- 挂死隔离:把"不可中断的 block"转成"可重试的 crash"——按进度心跳而非总时长判定复制挂死,超时回收并归为普通 IO 错误(我们补
TIMEOUT码 + 根 suspect)。 - 写前顺带清本目录旧 partial(24h,只清本目录、不全库扫)。
- 空目录上溯清理的边界:不删一级路径、不越过下载/库根、有媒体即停、网络/FUSE 可关、仅全部成功后执行。
latest旧版本的判定口径:同季 + 同集 + 同 Part + 仅视频扩展名(我们用作处置候选,不直接删)。- 不复制权限/属主,尊重目标目录继承 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× 哈希时间。本次顺带去掉了
crossDeviceMoveverify 步骤对两边的重复全量哈希(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 冻结待有环境再测。