From deaebf22b49182ad198f69d490a0bbe2ea07cf1e Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Sun, 13 Sep 2026 09:35:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(fam-edge):=20=E7=94=9F=E4=BA=A7=E8=80=85?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=E8=B7=B3=E8=BF=87=E5=B7=B2=E8=A2=AB=20DiskGu?= =?UTF-8?q?ard=20=E6=B8=85=E7=90=86=E7=9A=84=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产者列目录之后、读 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 --- PROGRESS.md | 5 ++ fam-edge/src/fam_edge/video_queue.py | 10 +++- fam-edge/tests/test_video_queue.py | 73 ++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 fam-edge/tests/test_video_queue.py diff --git a/PROGRESS.md b/PROGRESS.md index 644b917..b001a08 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -643,3 +643,8 @@ DiskGuard「检查异常」 2837 次 ← 22 倍 **测试**:新增 2 个用例(8 线程 × 25 轮并发读写、close 要收掉所有连接)。在旧代码上 稳定复现同族错误 `cannot commit transaction - SQL statements in progress`,修复后通过; fam-edge 全套 159 个测试绿。 + +**同批修掉的第二个竞态**:生产者列目录之后、读 mtime 之前,DiskGuard 可能刚好把那个 +文件清掉(两个后台线程的正常竞态),`os.path.getmtime` 抛 FileNotFoundError,代价是 +**整轮扫描中断**——排在后面的新素材本轮全都登记不上。改成捕获 OSError 跳过该文件, +新增 `fam-edge/tests/test_video_queue.py` 两个用例覆盖(被删的跳过 / 仍在写入的照样跳过)。 diff --git a/fam-edge/src/fam_edge/video_queue.py b/fam-edge/src/fam_edge/video_queue.py index 5a7eae3..1217bdd 100644 --- a/fam-edge/src/fam_edge/video_queue.py +++ b/fam-edge/src/fam_edge/video_queue.py @@ -84,7 +84,15 @@ class VideoQueue: if row is None: # 新文件:先过 mtime 稳定窗口 + 可解码校验,通过才登记入队;失败标记 invalid if self.file_validate: - if time.time() - os.path.getmtime(path) < self.stable_window_sec: + try: + mtime = os.path.getmtime(path) + except OSError: + # 列目录之后、读 mtime 之前,DiskGuard 可能刚好把这个文件清掉了 + # (两个后台线程的正常竞态)。跳过它就行——不 catch 的话整轮扫描 + # 会被这一个文件中断,后面的新素材本轮都登记不上。 + logger.info(f"文件已不在(可能刚被 DiskGuard 清理),跳过本轮: {fn}") + continue + if time.time() - mtime < self.stable_window_sec: logger.info(f"文件仍在写入(mtime 未稳定),跳过本轮: {fn}") continue ok, verr, vmeta = validate_video(path) diff --git a/fam-edge/tests/test_video_queue.py b/fam-edge/tests/test_video_queue.py new file mode 100644 index 0000000..8ed1247 --- /dev/null +++ b/fam-edge/tests/test_video_queue.py @@ -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 == []