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>
This commit is contained in:
Aqr-K
2026-08-13 08:19:54 +08:00
committed by GitHub
co-authored by Aqr-K
parent 4d11a38496
commit a2e70b443d
62 changed files with 7889 additions and 360 deletions
@@ -1,2 +1,16 @@
class TMDbException(Exception):
pass
class TMDbConnectionError(TMDbException):
"""
TMDB连接失败异常。
仅在确认为传输层/响应格式问题(如底层HTTP请求失败、响应无法解析为JSON)时抛出,
与TMDB业务层明确返回的错误(如404条目不存在、参数错误等,仍抛出普通TMDbException
区分开,便于上层区分"网络故障,请重试""条目确实不存在"两类完全不同的处理与文案。
继承自TMDbException,因此现有 `except TMDbException` 代码路径无需修改即可
继续捕获本异常,保持向后兼容。
"""
pass
+48 -10
View File
@@ -12,10 +12,30 @@ import requests.exceptions
from app.core.cache import cached, fresh, async_fresh
from app.core.config import settings
from app.utils.http import RequestUtils, AsyncRequestUtils
from .exceptions import TMDbException
from .exceptions import TMDbException, TMDbConnectionError
logger = logging.getLogger(__name__)
# 单次重试前的退避等待时间(秒)。NAS+FUSE网盘等环境下TMDB连接的失败大多是数秒内
# 可自愈的瞬时抖动,零间隔重试(或异步完全不重试)基本无法穿越这类抖动窗口;
# 识别链路是同步阻塞调用,1-3秒的等待可接受,超出3秒则会让识别耗时明显变长,
# 故取区间内的经验值。
RETRY_BACKOFF_SECONDS = 2
def _is_business_failure_snapshot(snapshot) -> bool:
"""
判断响应快照是否为TMDB业务失败(success=false,如404/限流/服务端错误的合法JSON)。
这类响应若入缓存会把瞬时失败固化整个TTL周期(如12小时),期间同key请求
直接命中失败快照;跳过缓存让下次请求重新确认。真正的负缓存(条目确认
不存在)由上层 TmdbCache 负责,request 层不做失败记忆。
"""
if not isinstance(snapshot, dict):
return False
json_data = snapshot.get("json")
return isinstance(json_data, dict) and json_data.get("success") is False
class TMDb(object):
_RESPONSE_SNAPSHOT_MARKER = "__mp_tmdb_response_snapshot__"
@@ -136,15 +156,21 @@ class TMDb(object):
def wait_on_rate_limit(self, wait_on_rate_limit):
self._wait_on_rate_limit = bool(wait_on_rate_limit)
@cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta, skip_none=True)
@cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta, skip_none=True,
skip_if=_is_business_failure_snapshot)
def request(self, method, url, data, json, **kwargs):
req = self._request_once(method, url, data, json)
if req is None and method == "GET" and self._owns_session:
logger.debug("TMDB同步请求失败,重建会话重试一次")
logger.debug(f"TMDB同步请求失败,等待{RETRY_BACKOFF_SECONDS}秒后重建会话重试一次")
# 同步阻塞识别链线程;1-3秒的退避等待可接受,能显著提升对瞬时抖动的容错,
# 详见模块级常量 RETRY_BACKOFF_SECONDS 的说明。
time.sleep(RETRY_BACKOFF_SECONDS)
self._reset_owned_session()
req = self._request_once(method, url, data, json)
if req is None:
raise TMDbException("无法连接TheMovieDb,请检查网络连接!")
# 抛出更具体的连接异常子类,供上层(如TMDB详情查询)区分"网络故障"
# 与"TMDB业务层明确返回的错误"(如404条目不存在),两者不能混为一谈。
raise TMDbConnectionError("无法连接TheMovieDb,请检查网络连接!")
return self._snapshot_response(req)
def _request_once(self, method, url, data, json):
@@ -155,16 +181,28 @@ class TMDb(object):
return self._req.get_res(url, params=data, json=json)
return self._req.post_res(url, data=data, json=json)
@cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta, skip_none=True)
@cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta, skip_none=True,
skip_if=_is_business_failure_snapshot)
async def async_request(self, method, url, data, json, **kwargs):
if method == "GET":
req = await self._async_req.get_res(url, params=data, json=json)
else:
req = await self._async_req.post_res(url, data=data, json=json)
req = await self._async_request_once(method, url, data, json)
if req is None:
raise TMDbException("无法连接TheMovieDb,请检查网络连接!")
logger.debug(f"TMDB异步请求失败,等待{RETRY_BACKOFF_SECONDS}秒后重试一次")
# 异步会话(AsyncRequestUtils)不像同步会话那样支持按需重建,
# 这里退化为原会话上的纯重试,同样以退避等待应对瞬时抖动。
await asyncio.sleep(RETRY_BACKOFF_SECONDS)
req = await self._async_request_once(method, url, data, json)
if req is None:
raise TMDbConnectionError("无法连接TheMovieDb,请检查网络连接!")
return self._snapshot_response(req)
async def _async_request_once(self, method, url, data, json):
"""
执行一次TMDB异步请求,调用方负责决定是否重试。
"""
if method == "GET":
return await self._async_req.get_res(url, params=data, json=json)
return await self._async_req.post_res(url, data=data, json=json)
@classmethod
def _snapshot_response(cls, response):
"""