diff --git a/utils/atomic_io.py b/utils/atomic_io.py index 3ac2b8c1..8b4596bd 100644 --- a/utils/atomic_io.py +++ b/utils/atomic_io.py @@ -7,6 +7,10 @@ Windows Search 索引、同进程并发的读取线程都会造成这类短暂 POSIX 下 rename 不受打开句柄影响,无此问题。 对策:仅对这两种 winerror 做短退避重试;其他错误立即抛出。 + +2026-08 调整:默认预算由 6 次/约 1.55s 上调至 8 次/约 4.75s—— +实测 Defender 实时扫描 / Search 索引器等外部程序偶发持锁超过原预算 +(如对话索引 index.json 保存失败 WinError 5),提高预算以覆盖此类场景。 """ from __future__ import annotations @@ -25,13 +29,14 @@ def replace_with_retry( src: PathLike, dst: PathLike, *, - attempts: int = 6, + attempts: int = 8, initial_delay: float = 0.05, + max_delay: float = 1.6, ) -> None: """os.replace 的 Windows 加固版:对瞬时持锁做指数退避重试。 - attempts 为总尝试次数(含首次),退避序列默认 0.05/0.1/0.2/0.4/0.8s, - 最坏情况约 1.55s。最后一次失败时原样抛出该 OSError。 + attempts 为总尝试次数(含首次),退避序列默认 0.05/0.1/0.2/0.4/0.8/1.6/1.6s, + 最坏情况约 4.75s。最后一次失败时原样抛出该 OSError。 """ delay = initial_delay total = max(1, int(attempts)) @@ -43,4 +48,4 @@ def replace_with_retry( if getattr(exc, "winerror", None) not in _RETRY_WINERRORS or i == total - 1: raise time.sleep(delay) - delay = min(delay * 2, 0.8) + delay = min(delay * 2, max_delay) diff --git a/utils/conversation_manager/index_mixin.py b/utils/conversation_manager/index_mixin.py index 1076764e..4b43f2ca 100644 --- a/utils/conversation_manager/index_mixin.py +++ b/utils/conversation_manager/index_mixin.py @@ -11,15 +11,16 @@ from typing import Dict, List, Optional, Any from dataclasses import dataclass from utils.atomic_io import replace_with_retry +from utils.log_rotation import append_line try: - from config import DATA_DIR, HOST_WORKSPACES_FILE + from config import DATA_DIR, HOST_WORKSPACES_FILE, LOGS_DIR except ImportError: import sys from pathlib import Path project_root = Path(__file__).resolve().parents[1] if str(project_root) not in sys.path: sys.path.insert(0, str(project_root)) - from config import DATA_DIR, HOST_WORKSPACES_FILE + from config import DATA_DIR, HOST_WORKSPACES_FILE, LOGS_DIR try: from utils.perf_log import perf_log @@ -117,7 +118,18 @@ class IndexMixin: os.fsync(fh.fileno()) # Windows 下目标文件被并发读取/杀软扫描短暂持锁时 replace 会抛 # WinError 5/32,replace_with_retry 做短退避重试(POSIX 行为不变) - replace_with_retry(temp_path, target_file) + try: + replace_with_retry(temp_path, target_file) + except OSError: + # 重试预算耗尽仍失败:把已写好的新内容转移为 .last_failed.tmp 留证, + # 便于事后恢复与频率统计(固定文件名覆盖,不累积垃圾);原异常继续抛出 + try: + orphan = target_file.with_name(f".{target_file.name}.last_failed.tmp") + replace_with_retry(temp_path, orphan) + temp_path = None # 已转移,finally 不再删除 + except OSError: + pass + raise finally: if temp_path and temp_path.exists(): try: @@ -198,27 +210,29 @@ class IndexMixin: try: index: Dict = {} if self.index_file.exists(): + # 只在 with 块内读字节:json.loads 期间不持有文件句柄,避免 + # Windows 下读取方持锁阻塞写方 os.replace(无 FILE_SHARE_DELETE) with open(self.index_file, 'r', encoding='utf-8') as f: content = f.read().strip() - if content: - index = json.loads(content) - if index: - if ensure_integrity and not self._index_verified: - if self._index_missing_conversations(index): - rebuilt = self._rebuild_index_from_files(max_count=max_rebuild) - if rebuilt: - self._save_index(rebuilt) - index = rebuilt - self._index_verified = True - return index - # 索引为空但对话文件仍然存在时尝试重建 - rebuilt = self._rebuild_index_from_files(max_count=max_rebuild) - if rebuilt: - self._save_index(rebuilt) - if ensure_integrity: - self._index_verified = True - return rebuilt - return {} + if content: + index = json.loads(content) + if index: + if ensure_integrity and not self._index_verified: + if self._index_missing_conversations(index): + rebuilt = self._rebuild_index_from_files(max_count=max_rebuild) + if rebuilt: + self._save_index(rebuilt) + index = rebuilt + self._index_verified = True + return index + # 索引为空但对话文件仍然存在时尝试重建 + rebuilt = self._rebuild_index_from_files(max_count=max_rebuild) + if rebuilt: + self._save_index(rebuilt) + if ensure_integrity: + self._index_verified = True + return rebuilt + return {} # 索引缺失但存在对话文件时重建 rebuilt = self._rebuild_index_from_files(max_count=max_rebuild) if rebuilt: @@ -254,6 +268,15 @@ class IndexMixin: self._atomic_write_json(self.index_file, index) except Exception as e: print(f"⌘ 保存对话索引失败: {e}") + # 落盘留证(线程安全、自动轮转、异常自吞),便于统计失败频率与诊断 + append_line( + Path(LOGS_DIR) / "conversation_index_failures.log", + json.dumps({ + "ts": datetime.now().isoformat(timespec="milliseconds"), + "index_file": str(self.index_file), + "error": str(e), # str 含 [WinError N] 与源/目标路径,repr 会丢失 + }, ensure_ascii=False), + ) def _ensure_index_covering(self, limit: int, offset: int) -> Dict: """