"""孤儿数据清理器。 定时扫描存储目录与数据库,清理不再有意义的死数据: 1. 磁盘上存在但没有对应任务记录的上传/步骤目录(删除任务中断等残留)。 2. 状态为 COMPLETED 但产物文件已全部丢失、且超过宽限期的任务记录 (这类任务在任务页会显示"完成"但下载全部 404,属于孤儿数据)。 出于安全考虑,以下数据**不会**被自动清理: - FAILED 任务(用户可能重试,且失败任务本就可能没有文件)。 - 状态非终态(QUEUED/RUNNING)的任务。 - 最近宽限期内的任务,避免误删刚完成的运行。 手动删除任务仍走删除接口,本模块只做保守的孤儿兜底。 """ from __future__ import annotations import shutil import threading import time from datetime import datetime, timezone from pathlib import Path from wov_app.config import CLEANUP_GRACE_SECONDS, CLEANUP_INTERVAL_SECONDS from wov_app.db import Database class OrphanCleaner: """后台孤儿清理器:周期扫描并清理孤儿数据,只保留明确的死数据。""" def __init__( self, db: Database, storage_dir: Path, interval_seconds: float | None = None, grace_seconds: float | None = None, ) -> None: """保存依赖并初始化轮询线程控制字段。""" self.db = db self.storage_dir = storage_dir self.interval_seconds = interval_seconds or CLEANUP_INTERVAL_SECONDS self.grace_seconds = grace_seconds or CLEANUP_GRACE_SECONDS self._thread: threading.Thread | None = None self._stopping = False def start(self) -> None: """启动清理线程;重复调用无副作用。""" if self._thread is not None: return self._stopping = False self._thread = threading.Thread( target=self._loop, name="wov-orphan-cleaner", daemon=True, ) self._thread.start() def stop(self) -> None: """请求停止并等待清理线程退出。""" self._stopping = True if self._thread is not None: self._thread.join(timeout=5) self._thread = None def _loop(self) -> None: """周期循环:每隔一个间隔执行一次清理。""" while not self._stopping: time.sleep(self.interval_seconds) self.clean_once() def clean_once(self) -> int: """执行一次孤儿清理,返回清理的数据条目数。""" run_ids = set(self.db.list_run_ids()) removed = 0 # 1) 无对应任务记录的上传/步骤目录视为残留,直接删除。 removed += self._clean_dangling(self.storage_dir / "uploads", run_ids) removed += self._clean_dangling(self.storage_dir / "runs", run_ids) # 2) COMPLETED 且产物文件全失、超过宽限期的任务记录删除。 for run_id in run_ids: run = self.db.get_run(run_id) if run is None: continue # 批量处理运行(source=batch)跳过清理:其工作空间位于独立的 # storage/batch 层级,_has_files 检查主 runs 目录会误判为孤儿。 # 批量记录与私有工作空间由批量引擎负责收尾,用户媒体目录始终保留。 if run.get("source") == "batch": continue if run["status"] != "COMPLETED": continue if not self._expired(run.get("updated_at")): continue if self._has_files(self.storage_dir / "runs" / run_id): continue removed += self._remove_run(run_id, run) return removed def _clean_dangling(self, root: Path, run_ids: set[str]) -> int: """删除 root 下没有对应任务记录的残留子目录,返回删除数。""" if not root.is_dir(): return 0 removed = 0 for child in root.iterdir(): if child.is_dir() and child.name not in run_ids: shutil.rmtree(child, ignore_errors=True) removed += 1 return removed def _expired(self, updated_at: str | None) -> bool: """判断任务最后更新时间是否已超过宽限期;无法解析时保守视为未过期。""" if not updated_at: return False try: updated = datetime.fromisoformat(updated_at) return (datetime.now(timezone.utc) - updated).total_seconds() > self.grace_seconds except ValueError: # 时间格式损坏时保守保留,避免误删。 return False def _has_files(self, run_dir: Path) -> bool: """判断任务目录下是否仍存在产物文件。""" if not run_dir.is_dir(): return False return any(path.is_file() for path in run_dir.rglob("*")) def _remove_run(self, run_id: str, run: dict) -> int: """删除孤儿上传任务的记录与私有目录,保留任何外部输入路径。""" self.db.delete_run(run_id) # 与手动删除一致,只按私有存储布局定位;历史记录的 input_uri # 可能引用用户媒体库,不能把其父目录当作可递归删除的上传目录。 shutil.rmtree(self.storage_dir / "uploads" / run_id, ignore_errors=True) shutil.rmtree(self.storage_dir / "runs" / run_id, ignore_errors=True) return 1