Files
vrsub/src/wov_app/maintenance.py
T

135 lines
5.4 KiB
Python

"""孤儿数据清理器。
定时扫描存储目录与数据库,清理不再有意义的死数据:
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