fix(monitor): 避免下载临时文件触发未稳定告警 (#7215)

This commit is contained in:
kaestnerheisser207-web
2026-10-27 12:41:49 +08:00
committed by GitHub
parent e1b276bf9b
commit bde74da51d
2 changed files with 144 additions and 0 deletions

View File

@@ -322,6 +322,9 @@ class LocalDirectoryWatcher:
if change_type not in self._HANDLE_CHANGES:
continue
event_path = Path(path_str)
# 下载临时文件会持续写入,须在稳定性排队前排除,避免正常下载触发超时告警。
if self._is_download_temporary_file(event_path):
continue
event = self._build_event(change_type=change_type, event_path=event_path)
if not event:
# 「文件已消失」与「挂载抖动瞬时不可见」在此刻无法区分,静默丢弃就是
@@ -353,6 +356,19 @@ class LocalDirectoryWatcher:
except Exception as err:
logger.error(f"处理本地目录监控事件失败: {path_str} - {err}")
@staticmethod
def _is_download_temporary_file(event_path: Path) -> bool:
"""
按当前下载临时后缀配置过滤文件,沿用整理分发器的大小写不敏感匹配。
:param event_path: 待检查的监控路径
:return: 文件后缀是否属于配置的下载临时后缀
"""
return bool(event_path.suffix) and event_path.suffix.casefold() in {
extension.casefold()
for extension in get_runtime_setting('DOWNLOAD_TMPEXT')
}
def _queue_stability_event(self, change_type: Change, event_path: Path,
file_size: int) -> None:
"""
@@ -409,6 +425,9 @@ class LocalDirectoryWatcher:
if not item:
continue
event_path = item["path"]
# 配置可能在文件入队后变化,告警前再次核对,避免旧条目继续误报。
if self._is_download_temporary_file(event_path):
continue
if (
not item.get("warning_sent")
and now - item.get("first_seen", now) >= self.FILE_STABILITY_WARNING_SECONDS

View File

@@ -6,6 +6,7 @@ import threading
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from watchfiles import Change
from app.application.history import TransferHistorySnapshot
@@ -174,6 +175,130 @@ def test_running_watcher_reports_long_unstable_file_and_recovery(tmp_path, monke
assert len(callback.events) == 1
@pytest.mark.parametrize("suffix", [".!qB", ".!QB", ".part", ".PART", ".aria2"])
def test_running_watcher_ignores_download_temporary_files(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, suffix: str
) -> None:
"""
配置的临时下载后缀不进入稳定性队列,长期写入也不应告警。
:param tmp_path: 隔离的临时下载目录
:param monkeypatch: 替换运行配置、监控线程状态和单调时钟
:param suffix: 下载器使用的临时后缀,包含大小写和自定义后缀
"""
monkeypatch.setattr("app.monitor.watcher.get_runtime_setting", lambda _: [".!qb", ".part", ".aria2"])
monkeypatch.setattr("app.monitor.watcher.time.monotonic", lambda: 1000.0)
temporary_file = tmp_path / f"episode.mp4{suffix}"
temporary_file.write_bytes(b"downloading")
callback = CallbackRecorder()
watcher = LocalDirectoryWatcher(tmp_path, callback=callback, force_polling=True)
monkeypatch.setattr(watcher, "is_alive", lambda: True)
watcher._handle_changes({(Change.added, temporary_file.as_posix())})
temporary_file.write_bytes(b"still downloading")
watcher._handle_changes({(Change.modified, temporary_file.as_posix())})
monkeypatch.setattr("app.monitor.watcher.time.monotonic", lambda: 2801.0)
watcher._process_pending_stability()
assert watcher._pending_stability == {}
assert callback.events == []
assert callback.stability_events == []
def test_running_watcher_dispatches_completed_download_after_rename(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""
临时文件下载完成并改名后,正式视频仍需稳定两次再分发一次。
:param tmp_path: 隔离的临时下载目录
:param monkeypatch: 替换临时后缀配置、监控线程状态和单调时钟
"""
monkeypatch.setattr("app.monitor.watcher.get_runtime_setting", lambda _: [".!qb"])
monkeypatch.setattr("app.monitor.watcher.time.monotonic", lambda: 1000.0)
temporary_file = tmp_path / "episode.mp4.!qB"
completed_file = tmp_path / "episode.mp4"
temporary_file.write_bytes(b"complete")
callback = CallbackRecorder()
watcher = LocalDirectoryWatcher(tmp_path, callback=callback, force_polling=True)
monkeypatch.setattr(watcher, "is_alive", lambda: True)
watcher._handle_changes({(Change.added, temporary_file.as_posix())})
monkeypatch.setattr("app.monitor.watcher.time.monotonic", lambda: 2801.0)
watcher._process_pending_stability()
temporary_file.rename(completed_file)
watcher._handle_changes({
(Change.deleted, temporary_file.as_posix()),
(Change.added, completed_file.as_posix()),
})
monkeypatch.setattr("app.monitor.watcher.time.monotonic", lambda: 2803.0)
watcher._process_pending_stability()
assert callback.events == []
monkeypatch.setattr("app.monitor.watcher.time.monotonic", lambda: 2805.0)
watcher._process_pending_stability()
assert [event[2] for event in callback.events] == [completed_file.as_posix()]
assert watcher._pending_stability == {}
assert callback.stability_events == []
def test_running_watcher_rechecks_temporary_suffix_for_pending_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""
临时后缀配置热更新后,已入队的文件不应继续产生超时告警。
:param tmp_path: 隔离的临时下载目录
:param monkeypatch: 替换运行配置、监控线程状态和单调时钟
"""
temporary_extensions: list[str] = []
monkeypatch.setattr("app.monitor.watcher.get_runtime_setting", lambda _: temporary_extensions)
monkeypatch.setattr("app.monitor.watcher.time.monotonic", lambda: 1000.0)
temporary_file = tmp_path / "episode.mp4.!qB"
temporary_file.write_bytes(b"downloading")
callback = CallbackRecorder()
watcher = LocalDirectoryWatcher(tmp_path, callback=callback, force_polling=True)
monkeypatch.setattr(watcher, "is_alive", lambda: True)
watcher._handle_changes({(Change.added, temporary_file.as_posix())})
assert temporary_file.as_posix() in watcher._pending_stability
temporary_extensions.append(".!qb")
monkeypatch.setattr("app.monitor.watcher.time.monotonic", lambda: 2801.0)
watcher._process_pending_stability()
assert watcher._pending_stability == {}
assert callback.events == []
assert callback.stability_events == []
def test_running_watcher_filters_temporary_files_in_added_directory(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""
新增目录递归展开时跳过临时文件,并保留正式视频的稳定性检测。
:param tmp_path: 隔离的目录树
:param monkeypatch: 替换监控运行配置和线程状态
"""
runtime_settings = {"DOWNLOAD_TMPEXT": [".!qb"], "MONITOR_RESCAN_DELAYS": "30,120"}
monkeypatch.setattr("app.monitor.watcher.get_runtime_setting", runtime_settings.__getitem__)
added_dir = tmp_path / "season"
added_dir.mkdir()
temporary_file = added_dir / "episode.mp4.!qB"
completed_file = added_dir / "complete.mp4"
temporary_file.write_bytes(b"downloading")
completed_file.write_bytes(b"complete")
callback = CallbackRecorder()
watcher = LocalDirectoryWatcher(tmp_path, callback=callback, force_polling=True)
monkeypatch.setattr(watcher, "is_alive", lambda: True)
watcher._handle_changes({(Change.added, added_dir.as_posix())})
assert set(watcher._pending_stability) == {completed_file.as_posix()}
assert callback.events == []
assert callback.stability_events == []
def test_handle_changes_skips_missing_paths(tmp_path):
"""
事件到达时已经消失的路径不应触发整理。