"""调试截图资源回收器:每小时清理,100MB 上限。 清理 /tmp/woc_debug/ 下的调试截图: - 按 mtime 删除超过 max_age_hours 的文件 - 总量超 max_total_mb 时按 LRU(最旧 mtime)删除最老文件直到达标 """ from __future__ import annotations import asyncio import logging import os import time from typing import Optional logger = logging.getLogger("woc-bridge") class ResourceReaper: """调试截图资源回收器。 Attributes: debug_screenshot_dir: 调试截图目录,默认 /tmp/woc_debug max_age_hours: 文件最大存活小时数,默认 24 max_total_mb: 目录总大小上限 MB,默认 100 interval: 检查间隔秒数,默认 3600(1 小时) """ def __init__( self, debug_screenshot_dir: str = "/tmp/woc_debug", max_age_hours: int = 24, max_total_mb: int = 100, interval: float = 3600.0, ) -> None: self.debug_screenshot_dir = debug_screenshot_dir self.max_age_hours = max_age_hours self.max_total_mb = max_total_mb self.interval = interval self._task: Optional[asyncio.Task] = None self._stopped = False async def start(self) -> None: """启动后台回收任务。""" if self._task is not None and not self._task.done(): return self._stopped = False self._task = asyncio.create_task(self._run()) logger.info( "[reaper] started (dir=%s max_age=%dh max_total=%dMB)", self.debug_screenshot_dir, self.max_age_hours, self.max_total_mb, ) async def stop(self) -> None: """停止回收任务。""" self._stopped = True if self._task is not None and not self._task.done(): self._task.cancel() try: await asyncio.wait_for(self._task, timeout=2.0) except (asyncio.TimeoutError, asyncio.CancelledError): pass self._task = None logger.info("[reaper] stopped") async def _run(self) -> None: """主循环:每 interval 秒清理一次。""" while not self._stopped: try: await asyncio.sleep(self.interval) await self.cleanup() except asyncio.CancelledError: break except Exception as exc: logger.warning("[reaper] cleanup exception: %s", exc) async def cleanup(self) -> None: """执行一次清理:TTL 删除 + 总量限制 LRU 删除。""" await asyncio.to_thread(self._cleanup_sync) def _cleanup_sync(self) -> None: """同步清理实现(在 to_thread 中执行)。""" if not os.path.isdir(self.debug_screenshot_dir): return now = time.time() max_age_sec = self.max_age_hours * 3600 # 1. 收集所有文件 + mtime + size files: list[tuple[str, float, int]] = [] # (path, mtime, size) for name in os.listdir(self.debug_screenshot_dir): path = os.path.join(self.debug_screenshot_dir, name) if not os.path.isfile(path): continue try: stat = os.stat(path) files.append((path, stat.st_mtime, stat.st_size)) except OSError: continue if not files: return # 2. 删除超 TTL 的文件 deleted_ttl = 0 survivors: list[tuple[str, float, int]] = [] for path, mtime, size in files: if now - mtime > max_age_sec: try: os.remove(path) deleted_ttl += 1 except OSError as exc: logger.debug("[reaper] remove %s failed: %s", path, exc) survivors.append((path, mtime, size)) else: survivors.append((path, mtime, size)) if deleted_ttl > 0: logger.info( "[reaper] deleted %d files (TTL > %dh)", deleted_ttl, self.max_age_hours ) # 3. 总量超限时按 LRU(最旧 mtime)删除 total_bytes = sum(s for _, _, s in survivors) max_bytes = self.max_total_mb * 1024 * 1024 if total_bytes <= max_bytes: return # 按 mtime 升序(最旧在前) survivors.sort(key=lambda x: x[1]) deleted_lru = 0 for path, _, size in survivors: if total_bytes <= max_bytes: break try: os.remove(path) total_bytes -= size deleted_lru += 1 except OSError as exc: logger.debug("[reaper] remove %s failed: %s", path, exc) if deleted_lru > 0: logger.info( "[reaper] deleted %d files (LRU, total now %.1fMB)", deleted_lru, total_bytes / (1024 * 1024), )