WechatOnCloud/bridge/woc_bridge/ui/resource_reaper.py

147 lines
4.8 KiB
Python
Raw Normal View History

"""调试截图资源回收器每小时清理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: 检查间隔秒数默认 36001 小时
"""
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),
)