fix(monitor): 修正远程快照文件数量累计

This commit is contained in:
jxxghp
2026-08-09 16:37:25 +08:00
parent 15c73240ac
commit 5c7e09d407
7 changed files with 199 additions and 29 deletions
+5 -2
View File
@@ -122,16 +122,19 @@ class StorageChain(ChainBase):
return self.run_module("get_parent_item", fileitem=fileitem) return self.run_module("get_parent_item", fileitem=fileitem)
def snapshot_storage(self, storage: str, path: Path, def snapshot_storage(self, storage: str, path: Path,
last_snapshot_time: float = None, max_depth: int = 5) -> Optional[Dict[str, Dict]]: last_snapshot_time: float = None, max_depth: int = 5,
previous_snapshot: Optional[Dict[str, Dict]] = None) -> Optional[Dict[str, Dict]]:
""" """
快照存储 快照存储
:param storage: 存储类型 :param storage: 存储类型
:param path: 路径 :param path: 路径
:param last_snapshot_time: 上次快照时间,用于增量快照 :param last_snapshot_time: 上次快照时间,用于增量快照
:param max_depth: 最大递归深度,避免过深遍历 :param max_depth: 最大递归深度,避免过深遍历
:param previous_snapshot: 上次完整快照,用于增量对账
""" """
return self.run_module("snapshot_storage", storage=storage, path=path, return self.run_module("snapshot_storage", storage=storage, path=path,
last_snapshot_time=last_snapshot_time, max_depth=max_depth) last_snapshot_time=last_snapshot_time, max_depth=max_depth,
previous_snapshot=previous_snapshot)
def storage_usage(self, storage: str) -> Optional[schemas.StorageUsage]: def storage_usage(self, storage: str) -> Optional[schemas.StorageUsage]:
""" """
+12 -2
View File
@@ -32,6 +32,7 @@ class FileManagerModule(_ModuleBase):
self.messagehelper = MessageHelper() self.messagehelper = MessageHelper()
def init_module(self) -> None: def init_module(self) -> None:
"""初始化文件整理模块支持的存储实现"""
# 加载模块 # 加载模块
self._storage_schemas = ModuleHelper.load('app.modules.filemanager.storages', self._storage_schemas = ModuleHelper.load('app.modules.filemanager.storages',
filter_func=lambda _, obj: hasattr(obj, 'schema') and obj.schema) filter_func=lambda _, obj: hasattr(obj, 'schema') and obj.schema)
@@ -40,6 +41,7 @@ class FileManagerModule(_ModuleBase):
@staticmethod @staticmethod
def get_name() -> str: def get_name() -> str:
"""获取模块名称"""
return "文件整理" return "文件整理"
@staticmethod @staticmethod
@@ -64,6 +66,7 @@ class FileManagerModule(_ModuleBase):
return 4 return 4
def stop(self): def stop(self):
"""停止文件整理模块"""
pass pass
def test(self) -> Tuple[bool, str]: def test(self) -> Tuple[bool, str]:
@@ -384,13 +387,15 @@ class FileManagerModule(_ModuleBase):
return storage_oper.get_parent(fileitem) return storage_oper.get_parent(fileitem)
def snapshot_storage(self, storage: str, path: Path, def snapshot_storage(self, storage: str, path: Path,
last_snapshot_time: float = None, max_depth: int = 5) -> Optional[Dict[str, Dict]]: last_snapshot_time: float = None, max_depth: int = 5,
previous_snapshot: Optional[Dict[str, Dict]] = None) -> Optional[Dict[str, Dict]]:
""" """
快照存储 快照存储
:param storage: 存储类型 :param storage: 存储类型
:param path: 路径 :param path: 路径
:param last_snapshot_time: 上次快照时间,用于增量快照 :param last_snapshot_time: 上次快照时间,用于增量快照
:param max_depth: 最大递归深度,避免过深遍历 :param max_depth: 最大递归深度,避免过深遍历
:param previous_snapshot: 上次完整快照,用于增量对账
""" """
if storage not in self._support_storages: if storage not in self._support_storages:
return None return None
@@ -398,7 +403,12 @@ class FileManagerModule(_ModuleBase):
if not storage_oper: if not storage_oper:
logger.error(f"不支持 {storage} 的快照处理") logger.error(f"不支持 {storage} 的快照处理")
return None return None
return storage_oper.snapshot(path, last_snapshot_time=last_snapshot_time, max_depth=max_depth) return storage_oper.snapshot(
path,
last_snapshot_time=last_snapshot_time,
max_depth=max_depth,
previous_snapshot=previous_snapshot
)
def storage_usage(self, storage: str) -> Optional[StorageUsage]: def storage_usage(self, storage: str) -> Optional[StorageUsage]:
""" """
+38 -5
View File
@@ -55,6 +55,7 @@ class StorageBase(metaclass=ABCMeta):
pass pass
def generate_qrcode(self, *args, **kwargs) -> Optional[Tuple[dict, str]]: def generate_qrcode(self, *args, **kwargs) -> Optional[Tuple[dict, str]]:
"""生成存储登录二维码"""
pass pass
def generate_auth_url(self, *args, **kwargs) -> Optional[Tuple[dict, str]]: def generate_auth_url(self, *args, **kwargs) -> Optional[Tuple[dict, str]]:
@@ -64,6 +65,7 @@ class StorageBase(metaclass=ABCMeta):
return {}, "此存储不支持 OAuth2 授权" return {}, "此存储不支持 OAuth2 授权"
def check_login(self, *args, **kwargs) -> Optional[Dict[str, str]]: def check_login(self, *args, **kwargs) -> Optional[Dict[str, str]]:
"""检查存储登录状态"""
pass pass
def get_config(self) -> Optional[schemas.StorageConf]: def get_config(self) -> Optional[schemas.StorageConf]:
@@ -269,14 +271,40 @@ class StorageBase(metaclass=ABCMeta):
""" """
pass pass
def snapshot(self, path: Path, last_snapshot_time: float = None, max_depth: int = 5) -> Dict[str, Dict]: def snapshot(self, path: Path, last_snapshot_time: float = None, max_depth: int = 5,
previous_snapshot: Optional[Dict[str, Dict]] = None) -> Dict[str, Dict]:
""" """
快照文件系统,输出所有层级文件信息(不含目录) 快照文件系统,输出所有层级文件信息(不含目录)
:param path: 路径 :param path: 路径
:param last_snapshot_time: 上次快照时间,用于增量快照 :param last_snapshot_time: 上次快照时间,用于增量快照
:param max_depth: 最大递归深度,避免过深遍历 :param max_depth: 最大递归深度,避免过深遍历
:param previous_snapshot: 上次完整快照,用于保留未变化目录并清理已删除文件
""" """
files_info = {} root_path = PurePosixPath(path.as_posix())
files_info = {
file_path: file_info
for file_path, file_info in (previous_snapshot or {}).items()
if PurePosixPath(file_path).is_relative_to(root_path)
}
def __remove_deleted_children(_fileitm: schemas.FileItem,
sub_files: List[schemas.FileItem]) -> None:
"""
清理已确认遍历目录中不再存在的直接子项。
未变化的子目录仍保留旧基线,避免增量遍历将其误删。
"""
directory_path = PurePosixPath(_fileitm.path)
child_paths = {PurePosixPath(sub_file.path) for sub_file in sub_files}
for old_file_path in list(files_info):
try:
relative_path = PurePosixPath(old_file_path).relative_to(directory_path)
except ValueError:
continue
if not relative_path.parts:
continue
direct_child_path = directory_path / relative_path.parts[0]
if direct_child_path not in child_paths:
files_info.pop(old_file_path, None)
def __snapshot_file(_fileitm: schemas.FileItem, current_depth: int = 0): def __snapshot_file(_fileitm: schemas.FileItem, current_depth: int = 0):
""" """
@@ -288,15 +316,20 @@ class StorageBase(metaclass=ABCMeta):
if current_depth >= max_depth: if current_depth >= max_depth:
return return
# 增量检查:如果目录修改时间早于上次快照,跳过 # 根目录每轮至少列举一次,用于清理已移走的直接子项;子目录仍按修改时间增量遍历
if (self.snapshot_check_folder_modtime and if (current_depth > 0 and
self.snapshot_check_folder_modtime and
last_snapshot_time and last_snapshot_time and
_fileitm.modify_time and _fileitm.modify_time and
_fileitm.modify_time <= last_snapshot_time): _fileitm.modify_time <= last_snapshot_time):
return return
# 遍历子文件 # 只有目录列表成功返回后才清理旧基线,查询异常时继续保留待下轮重试
sub_files = self.list(_fileitm) sub_files = self.list(_fileitm)
if sub_files is None:
return
sub_files = list(sub_files)
__remove_deleted_children(_fileitm, sub_files)
for sub_file in sub_files: for sub_file in sub_files:
__snapshot_file(sub_file, current_depth + 1) __snapshot_file(sub_file, current_depth + 1)
else: else:
+8 -2
View File
@@ -167,12 +167,18 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
# 远程目录监控 - 使用智能间隔 # 远程目录监控 - 使用智能间隔
# 先尝试加载已有快照获取文件数量 # 先尝试加载已有快照获取文件数量
snapshot_data = self._store.load(storage) snapshot_data = self._store.load(storage)
file_count = snapshot_data.get('file_count', 0) if snapshot_data else 0 snapshot_is_current = (
snapshot_data
and snapshot_data.get('version') == SnapshotStore.VERSION
)
file_count = snapshot_data.get('file_count', 0) if snapshot_is_current else 0
interval = SnapshotStore.adjust_interval(file_count) interval = SnapshotStore.adjust_interval(file_count)
for path in paths: for path in paths:
logger.info(f"正在启动远程目录监控: {path} [{storage}]") logger.info(f"正在启动远程目录监控: {path} [{storage}]")
logger.info("*** 重要提示:远程目录监控只处理新增和修改的文件,不会处理监控启动前已存在的文件 ***") logger.info("*** 重要提示:远程目录监控只处理新增和修改的文件,不会处理监控启动前已存在的文件 ***")
logger.info(f"预估文件数量: {file_count}, 监控间隔: {interval}分钟") if snapshot_data and not snapshot_is_current:
logger.info(f"检测到旧版远程快照,将在首次轮询后重新校准: {storage}")
logger.info(f"上次快照文件数量: {file_count}, 监控间隔: {interval}分钟")
self._scheduler.add_job( self._scheduler.add_job(
self.polling_observer, self.polling_observer,
+36 -14
View File
@@ -1,5 +1,5 @@
import traceback import traceback
from pathlib import Path from pathlib import Path, PurePosixPath
from threading import Lock from threading import Lock
from typing import Callable, Dict, List, Optional from typing import Callable, Dict, List, Optional
@@ -64,6 +64,21 @@ class RemotePoller:
self._alert_cb(storage, f"远程目录监控已恢复: {storage}") self._alert_cb(storage, f"远程目录监控已恢复: {storage}")
self._failure_counts[storage] = 0 self._failure_counts[storage] = 0
@staticmethod
def _snapshot_for_path(snapshot: Dict[str, Dict], mon_path: Path) -> Dict[str, Dict]:
"""
提取指定监控目录范围内的快照。
:param snapshot: 完整存储快照
:param mon_path: 监控目录
:return: 目录范围内的快照
"""
root_path = PurePosixPath(mon_path.as_posix())
return {
file_path: file_info
for file_path, file_info in snapshot.items()
if PurePosixPath(file_path).is_relative_to(root_path)
}
def poll(self, storage: str, mon_paths: List[Path]) -> Optional[int]: def poll(self, storage: str, mon_paths: List[Path]) -> Optional[int]:
""" """
执行一轮轮询监控。 执行一轮轮询监控。
@@ -83,7 +98,7 @@ class RemotePoller:
last_snapshot_time = old_snapshot_data.get('timestamp', 0) if old_snapshot_data else 0 last_snapshot_time = old_snapshot_data.get('timestamp', 0) if old_snapshot_data else 0
is_first_snapshot = old_snapshot_data is None is_first_snapshot = old_snapshot_data is None
new_snapshot = {} path_snapshots = []
failed_paths = [] failed_paths = []
for mon_path in mon_paths: for mon_path in mon_paths:
logger.debug(f"开始对 {storage}:{mon_path} 进行快照...") logger.debug(f"开始对 {storage}:{mon_path} 进行快照...")
@@ -92,40 +107,47 @@ class RemotePoller:
snapshot = StorageChain().snapshot_storage( snapshot = StorageChain().snapshot_storage(
storage=storage, storage=storage,
path=mon_path, path=mon_path,
last_snapshot_time=last_snapshot_time last_snapshot_time=last_snapshot_time,
previous_snapshot=old_snapshot
) )
if snapshot is None: if snapshot is None:
failed_paths.append(str(mon_path)) failed_paths.append(mon_path)
logger.warn(f"获取 {storage}:{mon_path} 快照失败") logger.warn(f"获取 {storage}:{mon_path} 快照失败")
continue continue
new_snapshot.update(snapshot) path_snapshots.append(snapshot)
logger.info(f"{storage}:{mon_path} 快照完成,发现 {len(snapshot)} 个文件") logger.info(f"{storage}:{mon_path} 快照完成,发现 {len(snapshot)} 个文件")
if failed_paths and (is_first_snapshot or len(failed_paths) == len(mon_paths)): if failed_paths and (is_first_snapshot or len(failed_paths) == len(mon_paths)):
# 首次基线必须完整建立;全部路径失败时本轮没有有效数据,均不落盘 # 首次基线必须完整建立;全部路径失败时本轮没有有效数据,均不落盘
self._note_failure(storage, f"快照失败: {','.join(failed_paths)}") self._note_failure(storage, f"快照失败:{','.join(str(path) for path in failed_paths)}")
return None return None
# 增量快照只包含变化子树,必须与基线合并才是完整视图; # 成功路径已在存储层完成增量对账;失败路径继续保留旧基线,避免临时故障丢失状态
# 直接把增量当基线会导致下一轮把未扫到的旧文件全部误判为新增 current_snapshot = {}
merged_snapshot = {**old_snapshot, **new_snapshot} for failed_path in failed_paths:
file_count = len(merged_snapshot) current_snapshot.update(self._snapshot_for_path(old_snapshot, failed_path))
for path_snapshot in path_snapshots:
current_snapshot.update(path_snapshot)
file_count = len(current_snapshot)
if not is_first_snapshot: if not is_first_snapshot:
self._handle_changes(storage, old_snapshot, new_snapshot) self._handle_changes(storage, old_snapshot, current_snapshot)
else: else:
logger.info(f"{storage} 首次快照完成,共 {file_count} 个文件") logger.info(f"{storage} 首次快照完成,共 {file_count} 个文件")
logger.info("*** 首次快照仅建立基准,不会处理现有文件。后续监控将处理新增和修改的文件 ***") logger.info("*** 首次快照仅建立基准,不会处理现有文件。后续监控将处理新增和修改的文件 ***")
# 保存合并后的基线 # 保存当前完整基线
if not self._store.save(storage, merged_snapshot, file_count, last_snapshot_time): if not self._store.save(storage, current_snapshot, file_count, last_snapshot_time):
self._note_failure(storage, "保存快照基线失败") self._note_failure(storage, "保存快照基线失败")
return None return None
if failed_paths: if failed_paths:
# 部分路径失败:成功路径已合并,失败路径保留旧基线,下轮重试 # 部分路径失败:成功路径已合并,失败路径保留旧基线,下轮重试
self._note_failure(storage, f"部分路径快照失败: {','.join(failed_paths)}") self._note_failure(
storage,
f"部分路径快照失败: {','.join(str(path) for path in failed_paths)}"
)
else: else:
self._note_success(storage) self._note_success(storage)
return file_count return file_count
+8 -3
View File
@@ -11,6 +11,7 @@ class SnapshotStore:
""" """
远程目录监控快照的存取与比对。 远程目录监控快照的存取与比对。
""" """
VERSION = 2
def __init__(self, cache: Optional[FileCache] = None): def __init__(self, cache: Optional[FileCache] = None):
""" """
@@ -30,10 +31,14 @@ class SnapshotStore:
:return: 是否保存成功 :return: 是否保存成功
""" """
try: try:
snapshot_time = max((item.get('modify_time', 0) for item in snapshot.values()), default=None) snapshot_time = max(
if snapshot_time is None: last_snapshot_time or 0,
snapshot_time = last_snapshot_time or time.time() max((item.get('modify_time', 0) for item in snapshot.values()), default=0)
)
if not snapshot_time:
snapshot_time = time.time()
snapshot_data = { snapshot_data = {
'version': self.VERSION,
'timestamp': snapshot_time, 'timestamp': snapshot_time,
'file_count': file_count, 'file_count': file_count,
'snapshot': snapshot 'snapshot': snapshot
+92 -1
View File
@@ -1,7 +1,11 @@
import json
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock
from app import schemas
from app.modules.filemanager.storages import StorageBase
from app.monitor.poller import RemotePoller from app.monitor.poller import RemotePoller
from app.monitor.snapshot import SnapshotStore
from app.monitor.watcher import LocalDirectoryWatcher from app.monitor.watcher import LocalDirectoryWatcher
@@ -46,13 +50,17 @@ def test_poll_merges_incremental_into_baseline(monkeypatch):
""" """
poller, store, dispatcher = _build_poller() poller, store, dispatcher = _build_poller()
store.load_checked.return_value = (dict(BASELINE), True) store.load_checked.return_value = (dict(BASELINE), True)
_mock_storage_chain(monkeypatch, [{'/mon/b.mkv': {'size': 2, 'modify_time': 200}}]) chain = _mock_storage_chain(monkeypatch, [{
'/mon/a.mkv': {'size': 1, 'modify_time': 100},
'/mon/b.mkv': {'size': 2, 'modify_time': 200}
}])
file_count = poller.poll("u115", [Path("/mon")]) file_count = poller.poll("u115", [Path("/mon")])
assert file_count == 2 assert file_count == 2
saved_snapshot = store.save.call_args.args[1] saved_snapshot = store.save.call_args.args[1]
assert set(saved_snapshot.keys()) == {'/mon/a.mkv', '/mon/b.mkv'} assert set(saved_snapshot.keys()) == {'/mon/a.mkv', '/mon/b.mkv'}
assert chain.snapshot_storage.call_args.kwargs["previous_snapshot"] == BASELINE["snapshot"]
dispatcher.handle_file.assert_called_once() dispatcher.handle_file.assert_called_once()
assert dispatcher.handle_file.call_args.kwargs["event_path"] == Path('/mon/b.mkv') assert dispatcher.handle_file.call_args.kwargs["event_path"] == Path('/mon/b.mkv')
@@ -73,6 +81,89 @@ def test_poll_detects_modified_files(monkeypatch):
dispatcher.handle_file.assert_called_once() dispatcher.handle_file.assert_called_once()
def test_poll_removes_deleted_files_from_count(monkeypatch):
"""
已移出监控目录的文件应从完整基线和动态间隔计数中移除。
"""
poller, store, dispatcher = _build_poller()
store.load_checked.return_value = (dict(BASELINE), True)
_mock_storage_chain(monkeypatch, [{}])
assert poller.poll("alist", [Path("/mon")]) == 0
assert store.save.call_args.args[1] == {}
assert store.save.call_args.args[2] == 0
dispatcher.handle_file.assert_not_called()
def test_storage_snapshot_reconciles_deleted_children_and_keeps_skipped_subtree():
"""
增量遍历应删除已消失的直接子项,同时保留未变化子目录的旧基线。
"""
storage = MagicMock()
storage.snapshot_check_folder_modtime = True
root = schemas.FileItem(storage="alist", type="dir", path="/mon/", name="mon", modify_time=200)
kept_dir = schemas.FileItem(
storage="alist", type="dir", path="/mon/keep/", name="keep", modify_time=50
)
storage.get_item.return_value = root
storage.list.return_value = [kept_dir]
previous_snapshot = {
'/mon/gone.mkv': {'size': 1, 'modify_time': 100},
'/mon/keep/a.mkv': {'size': 2, 'modify_time': 50}
}
snapshot = StorageBase.snapshot(
storage,
Path("/mon"),
last_snapshot_time=100,
previous_snapshot=previous_snapshot
)
assert snapshot == {'/mon/keep/a.mkv': {'size': 2, 'modify_time': 50}}
def test_storage_snapshot_always_lists_monitor_root_for_deletions():
"""
即使根目录修改时间未推进,也应列举其直接子项以清理已移走文件。
"""
storage = MagicMock()
storage.snapshot_check_folder_modtime = True
storage.get_item.return_value = schemas.FileItem(
storage="alist", type="dir", path="/mon/", name="mon", modify_time=50
)
storage.list.return_value = []
snapshot = StorageBase.snapshot(
storage,
Path("/mon"),
last_snapshot_time=100,
previous_snapshot={'/mon/gone.mkv': {'size': 1, 'modify_time': 100}}
)
assert snapshot == {}
storage.list.assert_called_once()
def test_snapshot_store_marks_reconciled_format_and_keeps_cursor():
"""
新快照应标记对账格式,删除最新文件后游标也不能倒退。
"""
cache = MagicMock()
store = SnapshotStore(cache=cache)
assert store.save(
"alist",
{'/mon/a.mkv': {'size': 1, 'modify_time': 50}},
file_count=1,
last_snapshot_time=100
) is True
payload = json.loads(cache.set.call_args.args[1].decode("utf-8"))
assert payload["version"] == SnapshotStore.VERSION
assert payload["timestamp"] == 100
assert payload["file_count"] == 1
def test_poll_partial_failure_merges_success_and_keeps_baseline(monkeypatch): def test_poll_partial_failure_merges_success_and_keeps_baseline(monkeypatch):
""" """
部分路径快照失败时,成功路径合并落盘,失败路径保留旧基线。 部分路径快照失败时,成功路径合并落盘,失败路径保留旧基线。