fix(fam-edge): 生产者扫描跳过已被 DiskGuard 清理的文件
生产者列目录之后、读 mtime 之前,DiskGuard 可能刚好把那个文件删了(两个后台线程 的正常竞态),os.path.getmtime 抛 FileNotFoundError。代价不是少处理一个文件,而是 **整轮扫描中断**——排在后面的新素材本轮全都登记不上,要等下一轮。 线上日志:生产者扫描异常: [Errno 2] No such file or directory: /opt/fam-edge/gdrive_videos/20260911PM/Generic_ONVIF-001-20260911-234730-...mp4 (01:30:01 抛出,同一秒 DiskGuard 正在清理那批文件) 改成捕获 OSError 跳过该文件继续扫。新增 tests/test_video_queue.py:被删的跳过且 后面的照常登记、仍在写入的(mtime 太新)依然跳过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
73
fam-edge/tests/test_video_queue.py
Normal file
73
fam-edge/tests/test_video_queue.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""VideoQueue 生产者扫描的单测。
|
||||
|
||||
目前只覆盖一个点:生产者列目录之后、读 mtime 之前,DiskGuard 可能刚好把文件清掉
|
||||
(两个后台线程的正常竞态)。线上表现为 `生产者扫描异常: [Errno 2] No such file or
|
||||
directory`,代价是**整轮扫描中断**——排在后面的新素材本轮全都登记不上,要等下一轮。
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from fam_edge import video_queue as vq_mod
|
||||
from fam_edge.video_queue import VideoQueue
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""只实现生产者路径上用到的方法。"""
|
||||
def __init__(self):
|
||||
self.registered = []
|
||||
self.activities = []
|
||||
|
||||
def get_video_by_filename(self, fn):
|
||||
return None
|
||||
|
||||
def ensure_video(self, fn, path, camera_name=None):
|
||||
self.registered.append(fn)
|
||||
return len(self.registered)
|
||||
|
||||
def set_video_file_status(self, vid, valid, err, meta=None):
|
||||
pass
|
||||
|
||||
def record_activity(self, service, action, detail=''):
|
||||
self.activities.append((service, action))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def queue_with_two_files(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(vq_mod, 'load_config', lambda: {
|
||||
'gdrive_sync': {'local_dir': str(tmp_path), 'camera_name': '客厅'},
|
||||
'video_processing': {'file_validate': True, 'stable_window_sec': 60},
|
||||
})
|
||||
(tmp_path / "a_被删掉的.mp4").write_bytes(b"x")
|
||||
(tmp_path / "b_正常的.mp4").write_bytes(b"y")
|
||||
db = _FakeDB()
|
||||
q = VideoQueue(db)
|
||||
monkeypatch.setattr(q, '_enqueue', lambda vid: None)
|
||||
monkeypatch.setattr(vq_mod, 'validate_video', lambda p: (True, '', {'fps': 25}))
|
||||
return q, db
|
||||
|
||||
|
||||
def test_produce_skips_file_deleted_mid_scan_and_keeps_going(queue_with_two_files, monkeypatch):
|
||||
"""被删掉的那个跳过,后面的照常登记——不能整轮中断。"""
|
||||
q, db = queue_with_two_files
|
||||
real_getmtime = os.path.getmtime
|
||||
|
||||
def fake_getmtime(path):
|
||||
if 'a_被删掉的' in path:
|
||||
raise FileNotFoundError(2, 'No such file or directory', path)
|
||||
return real_getmtime(path) - 3600 # 早于稳定窗口,视为写入完成
|
||||
monkeypatch.setattr(vq_mod.os.path, 'getmtime', fake_getmtime)
|
||||
|
||||
q._produce_once() # 不抛异常
|
||||
|
||||
assert db.registered == ["b_正常的.mp4"] # 被删的跳过,后面的没受影响
|
||||
|
||||
|
||||
def test_produce_still_skips_files_being_written(queue_with_two_files, monkeypatch):
|
||||
"""mtime 太新(rclone 还在写)仍然要跳过,别把半成品入队。"""
|
||||
q, db = queue_with_two_files
|
||||
monkeypatch.setattr(vq_mod.os.path, 'getmtime', lambda p: __import__('time').time())
|
||||
|
||||
q._produce_once()
|
||||
|
||||
assert db.registered == []
|
||||
Reference in New Issue
Block a user