Files
MoviePilot/app/monitor/recovery.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

179 lines
7.3 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.
"""
监控恢复动作的可放弃执行单元。
FUSE/网络挂载有两种故障形态,下游程序的免疫力完全不同:
- crash 型:调用抛错(如 Transport endpoint is not connected。异常能被捕获
退避重启即可自愈,由 watcher 自身的重启循环覆盖。
- block 型:调用既不返回错误也不返回结果,永久悬挂。**没有任何超时参数能救
一个已经发出的 stat**,阻塞其上的线程无法被 Python 回收(线程没有强杀接口)。
本模块提供 block 型故障下唯一可行的两种自保手段:
1. RecoveryExecutor —— 把会触碰挂载的动作放进一次性守护线程执行,调用方只
等待有限时间。超时即放弃该线程(它会作为守护线程悬挂到进程退出),换取
调用方(健康检查这个全局自愈单点)永远活着。
2. probe_path —— 用子进程而非线程做挂载探测。子进程可以被 kill因此探测
本身是可放弃的,隔离期间可以无限次周期重试而不累积不可回收的资源。
"""
import subprocess
import sys
import threading
import time
from enum import Enum
from pathlib import Path
from typing import Callable, Dict, Optional
from app.log import logger
# 探测子进程执行的脚本:只对目标路径做一次 stat成功退出 0失败退出非 0。
# 用 sys.executable 而不是 test/stat 等外部命令,避免依赖发行版的 coreutils 布局。
_PROBE_SCRIPT = "import os, sys; os.stat(sys.argv[1])"
# 探测子进程超时后,等待它响应 SIGKILL 的宽限秒数。挂在 FUSE 上的进程可能一时
# 收不掉,宽限期满就不再等待,残留进程由后续 Popen 自动回收,绝不能无限等待
# ——否则「可放弃的探测」又变回一次不可放弃的阻塞。
_PROBE_KILL_GRACE = 5
class RecoveryState(str, Enum):
"""
一次恢复动作的执行结论。
"""
# 动作已在限定时间内执行完毕(内部抛异常也算完成,异常已记录)
COMPLETED = "completed"
# 超时仍未返回,判定为 block 型挂载故障,线程已被放弃
TIMEOUT = "timeout"
# 同 key 的上一个动作仍未结束,本次未提交,避免持续泄漏冻死的线程
BUSY = "busy"
class RecoveryExecutor:
"""
按 key 隔离的一次性恢复线程执行器。
每个 key 同一时刻最多有一个在途动作:上一个还冻着就不再提交新的,否则每个
健康检查周期都会在同一个死挂载上多泄漏一个线程。
"""
def __init__(self):
# key -> 该 key 最近一次提交的执行线程
self._running: Dict[str, threading.Thread] = {}
self._lock = threading.Lock()
def run(self, actions: Dict[str, Callable[[], None]], timeout: float) -> Dict[str, RecoveryState]:
"""
并发执行一批恢复动作,整批最多等待 timeout 秒。
并发而非串行是必需的:串行等待会让总耗时随监控目录数线性增长,
13 个目录都挂死时健康检查会被拖过下一个周期,等于又一次自我冻结。
:param actions: key -> 无参恢复动作
:param timeout: 整批动作的最长等待秒数
:return: key -> 执行结论
"""
results: Dict[str, RecoveryState] = {}
started = []
for key, action in actions.items():
thread = self._start(key, action)
if thread is None:
results[key] = RecoveryState.BUSY
logger.warn(f"上一次恢复动作仍未返回,本轮跳过以避免线程泄漏: {key}")
continue
started.append((key, thread))
deadline = time.monotonic() + timeout
for key, thread in started:
thread.join(timeout=max(0.0, deadline - time.monotonic()))
if thread.is_alive():
results[key] = RecoveryState.TIMEOUT
logger.error(f"恢复动作超过 {timeout} 秒未返回,判定挂载无响应并放弃该线程: {key}")
else:
results[key] = RecoveryState.COMPLETED
return results
def discard(self, key: str):
"""
丢弃一个 key 的在途记录。监控停止或配置重载时调用,避免残留条目
让重建后的同名目录被误判为 BUSY。
:param key: 动作标识
"""
with self._lock:
self._running.pop(key, None)
def clear(self):
"""
清空全部在途记录。已经冻死的线程无法回收,这里只是不再跟踪它们。
"""
with self._lock:
self._running.clear()
def _start(self, key: str, action: Callable[[], None]) -> Optional[threading.Thread]:
"""
为一个 key 启动执行线程,该 key 仍有在途动作时不启动。
:param key: 动作标识
:param action: 无参恢复动作
:return: 执行线程,未启动时为 None
"""
with self._lock:
running = self._running.get(key)
if running is not None and running.is_alive():
return None
thread = threading.Thread(
target=self._execute,
args=(key, action),
name=f"MoviePilot-MonitorRecovery-{key}"[:120],
daemon=True
)
self._running[key] = thread
thread.start()
return thread
@staticmethod
def _execute(key: str, action: Callable[[], None]):
"""
执行一个恢复动作,异常只记录不外抛,避免一个目录的失败连累整批恢复。
:param key: 动作标识
:param action: 无参恢复动作
"""
try:
action()
except Exception as err:
logger.error(f"执行目录监控恢复动作失败: {key} - {err}")
def probe_path(path: Path, timeout: float) -> bool:
"""
用可放弃的子进程探测一个路径是否仍能被访问。
必须是子进程:在本线程里直接 statblock 型故障下这个调用永不返回,探测
线程就成了又一个不可回收的悬挂线程;子进程可以在超时后被 kill因此隔离
期间可以无限次周期探测。
:param path: 待探测路径
:param timeout: 探测超时秒数
:return: 路径是否可访问
"""
try:
process = subprocess.Popen(
[sys.executable, "-c", _PROBE_SCRIPT, str(path)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
except Exception as err:
logger.error(f"启动挂载探测子进程失败: {path} - {err}")
return False
try:
return process.wait(timeout=timeout) == 0
except subprocess.TimeoutExpired:
logger.warn(f"挂载探测 {timeout} 秒无响应,判定挂载仍未恢复: {path}")
process.kill()
try:
# 不能用无超时的 wait():进程若卡在 FUSE 上收不掉 SIGKILL
# 这里就会替它把调用线程也一起挂住
process.wait(timeout=_PROBE_KILL_GRACE)
except subprocess.TimeoutExpired:
logger.warn(f"挂载探测子进程未能及时退出,交由系统回收: {path}")
return False
except Exception as err:
logger.error(f"挂载探测执行失败: {path} - {err}")
return False