Files
MoviePilot/tests/test_monitor_resilience.py

274 lines
8.8 KiB
Python

from pathlib import Path
from unittest.mock import MagicMock
from app.monitor import LocalDirectoryWatcher, Monitor
def _build_watcher(tmp_path, force_polling):
"""
构造测试用目录监控。
:param tmp_path: 监控目录
:param force_polling: 是否强制轮询
:return: 目录监控
"""
return LocalDirectoryWatcher(tmp_path, callback=MagicMock(), force_polling=force_polling)
def test_run_retries_with_backoff_in_compatibility_mode(tmp_path, monkeypatch):
"""
兼容模式下监控循环抛异常后应退避重启,而不是直接结束线程。
"""
monkeypatch.setattr(LocalDirectoryWatcher, "RESTART_BACKOFF", (0,))
watcher = _build_watcher(tmp_path, force_polling=True)
calls = []
def fake_run_watch(force_polling):
"""
模拟底层监控循环持续抛出 FUSE 错误。
"""
calls.append(force_polling)
if len(calls) >= 3:
watcher.stop()
raise OSError(131, "State not recoverable")
monkeypatch.setattr(watcher, "_run_watch", fake_run_watch)
watcher._run()
assert calls == [True, True, True]
assert watcher.restart_count == 2
def test_run_falls_back_to_polling_before_backoff(tmp_path, monkeypatch):
"""
快速模式失败应先降级为兼容模式重试,且降级不计入退避重启次数。
"""
monkeypatch.setattr(LocalDirectoryWatcher, "RESTART_BACKOFF", (0,))
watcher = _build_watcher(tmp_path, force_polling=None)
calls = []
def fake_run_watch(force_polling):
"""
模拟快速模式与兼容模式先后失败。
"""
calls.append(force_polling)
if len(calls) >= 2:
watcher.stop()
raise OSError("inotify watch limit reached")
monkeypatch.setattr(watcher, "_run_watch", fake_run_watch)
watcher._run()
assert calls == [None, True]
assert watcher.restart_count == 0
def test_run_returns_when_stop_requested(tmp_path, monkeypatch):
"""
收到停止信号后监控循环正常返回,不应触发重启。
"""
watcher = _build_watcher(tmp_path, force_polling=True)
calls = []
def fake_run_watch(force_polling):
"""
模拟收到停止信号后正常退出的监控循环。
"""
calls.append(force_polling)
monkeypatch.setattr(watcher, "_run_watch", fake_run_watch)
watcher._run()
assert calls == [True]
assert watcher.restart_count == 0
def test_is_stalled_detects_silent_failure(tmp_path):
"""
监控线程存活但长时间无活动时应判定为静默失效。
"""
watcher = _build_watcher(tmp_path, force_polling=True)
# 线程未启动时不做判定
assert watcher.is_stalled() is False
thread = MagicMock()
thread.is_alive.return_value = True
watcher._thread = thread
watcher._mark_activity()
assert watcher.is_stalled() is False
watcher._last_activity -= LocalDirectoryWatcher.STALL_TIMEOUT + 1
assert watcher.is_stalled() is True
def test_is_stalled_ignores_stopped_watcher(tmp_path):
"""
已请求停止的监控不应再被判定为静默失效。
"""
watcher = _build_watcher(tmp_path, force_polling=True)
thread = MagicMock()
thread.is_alive.return_value = True
watcher._thread = thread
watcher._mark_activity()
watcher._last_activity -= LocalDirectoryWatcher.STALL_TIMEOUT + 1
watcher.stop()
assert watcher.is_stalled() is False
def _build_monitor(monkeypatch, put_recorder):
"""
构造测试用 Monitor 骨架,绕过单例初始化。
:param monkeypatch: pytest monkeypatch
:param put_recorder: 消息推送记录器
:return: Monitor 骨架
"""
from threading import Lock
monkeypatch.setattr("app.monitor.monitor.MessageHelper", MagicMock(return_value=put_recorder))
monitor = object.__new__(Monitor)
monitor._watchers = []
monitor._watcher_lock = Lock()
monitor._pending_locals = []
monitor._alerted_paths = set()
monitor._restart_marks = {}
monitor._stable_cycles = {}
return monitor
def _fake_watcher(mon_path, alive=True, stalled=False, restart_count=0):
"""
构造测试用监控线程替身。
:param mon_path: 监控目录
:param alive: 线程是否存活
:param stalled: 是否静默失效
:param restart_count: 自动重启次数
:return: 监控线程替身
"""
watcher = MagicMock()
watcher.watch_path = mon_path
watcher.is_alive.return_value = alive
watcher.is_stalled.return_value = stalled
watcher.restart_count = restart_count
return watcher
def test_watchdog_rebuilds_dead_watcher(tmp_path, monkeypatch):
"""
监控线程退出后健康检查应重建线程并告警。
"""
put_recorder = MagicMock()
monitor = _build_monitor(monkeypatch, put_recorder)
watcher = _fake_watcher(tmp_path, alive=False)
monitor._watchers = [watcher]
rebuild = MagicMock()
setattr(monitor, "_Monitor__rebuild_watcher", rebuild)
monitor._Monitor__check_watchers()
rebuild.assert_called_once_with(watcher)
put_recorder.put.assert_called_once()
def test_watchdog_rebuilds_stalled_watcher(tmp_path, monkeypatch):
"""
静默失效的监控线程也应被健康检查重建。
"""
put_recorder = MagicMock()
monitor = _build_monitor(monkeypatch, put_recorder)
watcher = _fake_watcher(tmp_path, alive=True, stalled=True)
monitor._watchers = [watcher]
rebuild = MagicMock()
setattr(monitor, "_Monitor__rebuild_watcher", rebuild)
monitor._Monitor__check_watchers()
rebuild.assert_called_once_with(watcher)
def test_watchdog_alerts_on_restart_and_recovers_after_stable_window(tmp_path, monkeypatch):
"""
自动重启应触发一次告警,恢复消息需等满稳定窗口,避免来回刷屏。
"""
put_recorder = MagicMock()
monitor = _build_monitor(monkeypatch, put_recorder)
watcher = _fake_watcher(tmp_path, alive=True, stalled=False, restart_count=1)
monitor._watchers = [watcher]
monitor._Monitor__check_watchers()
assert str(tmp_path) in monitor._alerted_paths
assert put_recorder.put.call_count == 1
for _ in range(Monitor.RECOVERY_STABLE_CYCLES - 1):
monitor._Monitor__check_watchers()
assert str(tmp_path) in monitor._alerted_paths
monitor._Monitor__check_watchers()
assert str(tmp_path) not in monitor._alerted_paths
assert put_recorder.put.call_count == 2
def test_retry_pending_locals_backs_off(tmp_path, monkeypatch):
"""
启动失败的监控重试应按失败次数退避,避免持续故障时刷屏。
"""
put_recorder = MagicMock()
monitor = _build_monitor(monkeypatch, put_recorder)
monitor._pending_locals = [{"mon_path": tmp_path, "monitor_mode": "compatibility"}]
start = MagicMock(return_value=False)
setattr(monitor, "_Monitor__start_local_monitor", start)
for _ in range(6):
monitor._Monitor__retry_pending_locals()
assert start.call_count == 3
def test_dispatcher_retries_after_history_query_failure(monkeypatch):
"""
整理历史查询失败应登记待重试,重试成功后进入整理链并清除登记。
"""
from app.monitor.dispatcher import TransferDispatcher
dispatcher = TransferDispatcher(all_exts=[".mkv"], cache={})
event_path = Path("/downloads/movie.mkv")
history = MagicMock(side_effect=[None, False])
monkeypatch.setattr(dispatcher, "_has_transfer_history", history)
transfer_chain_instance = MagicMock()
monkeypatch.setattr("app.monitor.dispatcher.TransferChain",
MagicMock(return_value=transfer_chain_instance))
# 首次查询失败:不整理,登记待重试
assert dispatcher.handle_file(storage="local", event_path=event_path, file_size=1) is False
assert len(dispatcher._pending_retries) == 1
transfer_chain_instance.do_transfer.assert_not_called()
# 模拟 TTL 缓存过期后由健康检查驱动重试
dispatcher._cache.clear()
dispatcher.retry_pending()
transfer_chain_instance.do_transfer.assert_called_once()
assert dispatcher._pending_retries == {}
def test_dispatcher_drops_pending_after_max_attempts(monkeypatch):
"""
历史查询持续失败达到上限后应放弃重试,避免队列无限累积。
"""
from app.monitor.dispatcher import TransferDispatcher
dispatcher = TransferDispatcher(all_exts=[".mkv"], cache={})
event_path = Path("/downloads/movie.mkv")
monkeypatch.setattr(dispatcher, "_has_transfer_history", MagicMock(return_value=None))
dispatcher.handle_file(storage="local", event_path=event_path, file_size=1)
key = f"local:{event_path.as_posix()}"
assert key in dispatcher._pending_retries
dispatcher._pending_retries[key]["attempts"] = TransferDispatcher.MAX_RETRY_ATTEMPTS - 1
dispatcher._cache.clear()
dispatcher.retry_pending()
assert dispatcher._pending_retries == {}