Files
MoviePilot/tests/test_local_atomic_write.py
Aqr-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
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
本地存储写入原子性测试。
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)