mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
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:
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
有界重试预算的端到端行为验证。
|
||||
|
||||
app/helper/transferhistory.py 的查重闸真值表与计数器 API 已在
|
||||
tests/test_transfer_history_gate.py 逐项覆盖,本文件换一个角度:把「同一源路径
|
||||
连续多个监控事件」串成一条时间线,验证瞬时故障能在预算内自愈、耗尽预算后被拦、
|
||||
以及删除整理记录会让预算重新满额,贴近真实使用场景。
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.helper.transferhistory import (
|
||||
HistoryGateAction,
|
||||
clear_transfer_failures,
|
||||
evaluate_history_gate,
|
||||
failed_retry_count,
|
||||
record_transfer_failure,
|
||||
)
|
||||
|
||||
|
||||
def _reset_failed_retries(src_path, storage=None):
|
||||
"""清空失败重试计数,隔离用例之间共享的模块级计数缓存。"""
|
||||
clear_transfer_failures(src_path, storage)
|
||||
|
||||
|
||||
def _failed_history(history_id: int, src_path: str, storage: str):
|
||||
"""构造一条持续失败的整理记录替身,模拟同一源路径屡次整理失败后落库的状态。"""
|
||||
return SimpleNamespace(id=history_id, status=False, src_fileitem=None,
|
||||
src=src_path, src_storage=storage)
|
||||
|
||||
|
||||
def test_transient_failures_self_heal_within_retry_budget(monkeypatch):
|
||||
"""
|
||||
瞬时故障自愈:上限为 3 时,连续 2 次失败后第 3 个事件仍应放行重试;
|
||||
第 3 次也失败后计数达到上限,第 4 个事件应被拦截。
|
||||
"""
|
||||
monkeypatch.setattr(settings, "TRANSFER_MAX_FAILED_RETRIES", 3)
|
||||
src_path = "/downloads/retry-budget-self-heal.mkv"
|
||||
storage = "local"
|
||||
_reset_failed_retries(src_path, storage)
|
||||
try:
|
||||
history = _failed_history(1, src_path, storage)
|
||||
|
||||
# 事件 1:整理失败,登记第 1 次失败
|
||||
record_transfer_failure(src_path, storage)
|
||||
assert failed_retry_count(src_path, storage) == 1
|
||||
|
||||
# 事件 2:计数为 1,未达上限,放行重试;本次也失败,登记第 2 次失败
|
||||
assert evaluate_history_gate(history, file_size=None) == HistoryGateAction.PASS_FAILED
|
||||
record_transfer_failure(src_path, storage)
|
||||
assert failed_retry_count(src_path, storage) == 2
|
||||
|
||||
# 事件 3:连续失败 2 次后,计数为 2 仍未达上限(3),第 3 个事件仍应放行重试
|
||||
assert evaluate_history_gate(history, file_size=None) == HistoryGateAction.PASS_FAILED
|
||||
|
||||
# 第 3 次尝试也失败,登记第 3 次失败,计数达到上限
|
||||
record_transfer_failure(src_path, storage)
|
||||
assert failed_retry_count(src_path, storage) == 3
|
||||
|
||||
# 事件 4:计数达到上限(3),应被拦截,不再自动重试
|
||||
assert evaluate_history_gate(history, file_size=None) == HistoryGateAction.SKIP_RETRY_EXHAUSTED
|
||||
finally:
|
||||
_reset_failed_retries(src_path, storage)
|
||||
|
||||
|
||||
def test_clearing_transfer_history_restores_full_retry_budget(monkeypatch):
|
||||
"""删除整理记录清零:耗尽重试预算后调用 clear_transfer_failures,应重新获得放行资格。"""
|
||||
monkeypatch.setattr(settings, "TRANSFER_MAX_FAILED_RETRIES", 2)
|
||||
src_path = "/downloads/retry-budget-cleared-by-delete.mkv"
|
||||
storage = "local"
|
||||
_reset_failed_retries(src_path, storage)
|
||||
try:
|
||||
history = _failed_history(2, src_path, storage)
|
||||
|
||||
record_transfer_failure(src_path, storage)
|
||||
record_transfer_failure(src_path, storage)
|
||||
assert failed_retry_count(src_path, storage) == 2
|
||||
assert evaluate_history_gate(history, file_size=None) == HistoryGateAction.SKIP_RETRY_EXHAUSTED
|
||||
|
||||
# 用户删除整理记录,显式要求重来
|
||||
clear_transfer_failures(src_path, storage)
|
||||
|
||||
assert failed_retry_count(src_path, storage) == 0
|
||||
assert evaluate_history_gate(history, file_size=None) == HistoryGateAction.PASS_FAILED
|
||||
finally:
|
||||
_reset_failed_retries(src_path, storage)
|
||||
|
||||
|
||||
def test_delete_transfer_history_endpoint_clears_retry_count(monkeypatch):
|
||||
"""
|
||||
app/api/endpoints/history.py::delete_transfer_history 是用户删除整理记录的入口,
|
||||
删除时应连带清空失败重试计数,否则重整仍会受上一轮次数限制。
|
||||
|
||||
该端点依赖 SQLAlchemy Session 与鉴权依赖,这里按仓库内既有做法(参见
|
||||
tests/test_manual_transfer_history.py 对 app.api.endpoints.transfer 端点的用法)
|
||||
直接以关键字参数调用端点函数本身,绕开 FastAPI 的依赖注入,只替换端点内部
|
||||
实际用到的 TransferHistory.get / TransferHistory.delete 两个类方法。
|
||||
"""
|
||||
from app.api.endpoints.history import delete_transfer_history
|
||||
|
||||
src_path = "/downloads/retry-budget-delete-endpoint.mkv"
|
||||
storage = "local"
|
||||
history = SimpleNamespace(
|
||||
id=101,
|
||||
src=src_path,
|
||||
src_storage=storage,
|
||||
dest_fileitem=None,
|
||||
src_fileitem=None,
|
||||
download_hash=None,
|
||||
)
|
||||
monkeypatch.setattr("app.api.endpoints.history.TransferHistory.get",
|
||||
lambda db, history_id: history)
|
||||
monkeypatch.setattr("app.api.endpoints.history.TransferHistory.delete",
|
||||
lambda db, history_id: None)
|
||||
|
||||
_reset_failed_retries(src_path, storage)
|
||||
try:
|
||||
record_transfer_failure(src_path, storage)
|
||||
record_transfer_failure(src_path, storage)
|
||||
assert failed_retry_count(src_path, storage) == 2
|
||||
|
||||
response = delete_transfer_history(
|
||||
history_in=schemas.TransferHistory(id=101),
|
||||
deletesrc=False,
|
||||
deletedest=False,
|
||||
db=object(),
|
||||
_="token",
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
assert failed_retry_count(src_path, storage) == 0
|
||||
finally:
|
||||
_reset_failed_retries(src_path, storage)
|
||||
Reference in New Issue
Block a user