Files
MoviePilot/tests/test_local_atomic_write.py
T
Aqr-KandAqr-K a2e70b443d fix(monitor,transfer): 修复 FUSE 挂载无响应导致的监控冻死、整理链锁死与漏件 (#6276)
* wip(v3): 移植监控与整理韧性修复到 v3 基线

包含:监控看门狗隔离/挂载探测、整理队列持久化、文件系统子进程代理、
写入原子化。迁移重挂到 v3 链 8a4c7e1d2f90 -> 7f5c1d2e3a4b -> e3d9f4b7c806。
tmdb 相关测试尚未通过,待定位。

* fix(v3): 修正移植引入的 16 项测试失败

- poller.py:合并时我方保留的行仍用旧变量名 merged_snapshot,而 v3 已统一
  改名为 current_snapshot,导致 NameError 被外层 except 吞掉、快照从未保存
- smb.py:采纳 f-string 拆分写法,恢复 Python 3.11 可解析
- dispatcher 测试:历史查重由 _should_skip_by_history 统一承担,mock 点随之调整
- tmdb 缓存测试:补充 v3 新增的 media_source/media_id 字段
- tmdb 重试测试:为 fake 补充 match_multi/async_match_multi

尚余 3 项与 v3 识别流程的连接失败处理有关,待单独判断。

* fix(v3): 测试适配 v3 的 media_source/media_id 重构

v3 将媒体标识从 tmdbid 统一重构为 media_source + media_id,recognize_media
的 tmdbid 参数已被 **kwargs 静默吞掉——传了也不生效,流程会误降级到名称搜索。
tmdb 重试用例改用新参数后恢复正确路径。

同时修正 fake 的 match_multi 语义:真实实现(tmdbapi.match_multi)吞掉所有
异常并返回 None,连接失败与「未找到」在该路径上本就不可区分,fake 需保持一致。

至此移植引入的 19 项失败全部清零。

---------

Co-authored-by: Aqr-K <Aqr-K@users.noreply.github.com>
2026-08-13 08:19:54 +08:00

208 lines
6.9 KiB
Python

"""
本地存储写入原子性测试。
copy / 跨盘 move / upload 原先直接写目标路径,进程被杀(OOM、重启、宿主断电、
SIGKILL)时目标目录会留下一个**半截的媒体文件,而且叫最终文件名**——媒体库会
把它扫进去,后续的「目标已存在」检查也会把它当成完成品。
改成「写临时名 → os.replace」后,os.replace 在同目录内由内核保证原子性:
中断只可能留下一个带专用后缀的隐藏文件,最终文件要么完整存在,要么根本不存在。
"""
import os
from pathlib import Path
import pytest
from app.modules.filemanager.storages.local import LocalStorage
from app.schemas import FileItem
def _fileitem(path: Path) -> FileItem:
"""
构造本地文件项。
:param path: 文件路径
:return: 文件项
"""
return FileItem(
storage="local",
type="file",
path=path.as_posix(),
name=path.name,
basename=path.stem,
extension=path.suffix[1:],
)
@pytest.fixture
def storage():
"""
本地存储实例。
"""
return LocalStorage()
def test_copy_produces_complete_file(tmp_path, storage):
"""
正常复制的结果必须与直接复制一致。
"""
src = tmp_path / "src" / "Movie.2024.mkv"
src.parent.mkdir()
src.write_bytes(b"payload" * 100)
dest_dir = tmp_path / "library"
dest_dir.mkdir()
assert storage.copy(_fileitem(src), dest_dir, "Movie.2024.mkv") is True
assert (dest_dir / "Movie.2024.mkv").read_bytes() == b"payload" * 100
# 复制完成后不能残留任何临时文件
assert not list(dest_dir.glob(f"*{LocalStorage.PARTIAL_SUFFIX}"))
def test_copy_leaves_no_final_name_on_failure(tmp_path, storage, monkeypatch):
"""
复制中途失败时,目标文件名绝不能出现——这是本次修复的核心。
失败点选在内容写完、替换之前,正是原实现留下「完整文件名的半成品」的位置。
"""
src = tmp_path / "Movie.2024.mkv"
src.write_bytes(b"x" * 1000)
dest_dir = tmp_path / "library"
dest_dir.mkdir()
def boom(*_args, **_kwargs):
"""
模拟替换阶段被打断。
"""
raise OSError(28, "No space left on device")
monkeypatch.setattr(os, "replace", boom)
assert storage.copy(_fileitem(src), dest_dir, "Movie.2024.mkv") is False
# 最终文件名不存在,媒体库不会扫到半成品
assert not (dest_dir / "Movie.2024.mkv").exists()
# 临时文件也要清掉,不留垃圾
assert not list(dest_dir.glob(f"*{LocalStorage.PARTIAL_SUFFIX}"))
def test_partial_file_is_hidden_and_suffixed(tmp_path, storage):
"""
临时文件必须同时满足:点开头(隐藏)+ 专用后缀。
媒体库按扩展名识别媒体文件,点开头又能让多数扫描器跳过;两者叠加保证
即使进程被 SIGKILL、临时文件残留,也不会被当成媒体收录。
"""
partial = storage._partial_path(tmp_path / "Movie.2024.mkv")
assert partial.name.startswith(".")
assert partial.name.endswith(LocalStorage.PARTIAL_SUFFIX)
# 必须与目标同目录,os.replace 才是原子的(跨文件系统会退化成拷贝)
assert partial.parent == tmp_path
assert partial.suffix != ".mkv"
def test_move_produces_complete_file_and_removes_source(tmp_path, storage, monkeypatch):
"""
跨盘移动完成后目标完整、源被删除。
"""
src = tmp_path / "src" / "Movie.2024.mkv"
src.parent.mkdir()
src.write_bytes(b"data" * 50)
dest_dir = tmp_path / "library"
dest_dir.mkdir()
# 强制走跨盘路径(copy + unlink):只让「源 → 最终目标」的直接 rename 以
# EXDEV 失败,临时文件到目标的替换仍需正常工作
real_replace = os.replace
def exdev_for_source(source, target, *args, **kwargs):
"""
模拟源与目标不在同一文件系统。
"""
if Path(source) == src:
raise OSError(18, "Invalid cross-device link")
return real_replace(source, target, *args, **kwargs)
monkeypatch.setattr(os, "replace", exdev_for_source)
assert storage.move(_fileitem(src), dest_dir, "Movie.2024.mkv") is True
assert (dest_dir / "Movie.2024.mkv").read_bytes() == b"data" * 50
assert not src.exists()
assert not list(dest_dir.glob(f"*{LocalStorage.PARTIAL_SUFFIX}"))
def test_move_keeps_source_when_copy_fails(tmp_path, storage, monkeypatch):
"""
跨盘移动失败时源文件必须保留——先删源再失败就是永久丢件。
"""
src = tmp_path / "src" / "Movie.2024.mkv"
src.parent.mkdir()
src.write_bytes(b"data")
dest_dir = tmp_path / "library"
dest_dir.mkdir()
def boom(*_args, **_kwargs):
"""
模拟直接移动与临时文件替换都失败(磁盘写满)。
"""
raise OSError(28, "No space left on device")
monkeypatch.setattr(os, "replace", boom)
assert storage.move(_fileitem(src), dest_dir, "Movie.2024.mkv") is False
assert src.read_bytes() == b"data", "移动失败却删掉了源文件,等于永久丢件"
assert not (dest_dir / "Movie.2024.mkv").exists()
def test_same_path_move_is_noop(tmp_path, storage):
"""
源与目标相同时应直接成功,不能把文件写没了。
"""
media = tmp_path / "Movie.2024.mkv"
media.write_bytes(b"keep")
assert storage.move(_fileitem(media), tmp_path, "Movie.2024.mkv") is True
assert media.read_bytes() == b"keep"
def test_cleanup_removes_stale_partials_only(tmp_path, storage):
"""
清理只针对陈旧的临时文件:正在写入的临时文件与正常媒体文件都不能被误删。
不做全库扫描,只在实际写入的目录做局部清理——全库遍历在网络挂载上代价
不可接受,而残留只会出现在曾经写入过的目录里。
"""
stale = tmp_path / f".Old.mkv.1234{LocalStorage.PARTIAL_SUFFIX}"
fresh = tmp_path / f".New.mkv.5678{LocalStorage.PARTIAL_SUFFIX}"
media = tmp_path / "Keep.mkv"
for item in (stale, fresh, media):
item.write_bytes(b"x")
# 把陈旧临时文件的 mtime 推到阈值之前
old_time = os.stat(stale).st_mtime - LocalStorage.PARTIAL_STALE_SECONDS - 60
os.utime(stale, (old_time, old_time))
storage._cleanup_stale_partials(tmp_path)
assert not stale.exists(), "陈旧临时文件没有被清理"
assert fresh.exists(), "正在写入的临时文件被误删了"
assert media.exists(), "正常媒体文件被误删了"
def test_cleanup_never_raises(tmp_path, storage, monkeypatch):
"""
清理是尽力而为的旁路操作,任何失败都不能影响主流程。
"""
def boom(*_args, **_kwargs):
"""
模拟目录不可读。
"""
raise OSError(5, "Input/output error")
monkeypatch.setattr(Path, "glob", boom)
# 不抛异常即为通过
storage._cleanup_stale_partials(tmp_path)