135 lines
5.4 KiB
Python
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
|