feat: VRSub 单体应用(WOV 单机版)初始提交
为视频生成 VR 双眼字幕的单体实现:FastAPI 后端、调度器与全部节点 (提音/转写/翻译/ASS/抽帧/OCR/LLM 过滤)在单进程内运行。 - 节点协议(wov_sdk 数据模型)与分布式版保持一致,预留回退桥梁 - 工作流即数据:DAG 存于 workflows/*.json,模型/链路改动只改数据 - 调度器:拓扑顺序执行、断点续跑(产物重建)、任务暂停/继续 - 抽帧按帧间隔(select 按帧号精确取帧),VLM OCR 与 LLM 过滤使用 自适应线程池弹性并发,并打印数据处理速度进度日志 - 100% 行覆盖率(pytest --cov-fail-under=100)
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
"""孤儿数据清理器。
|
||||
|
||||
定时扫描存储目录与数据库,清理不再有意义的死数据:
|
||||
|
||||
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
|
||||
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 = run.get("input_uri")
|
||||
if input_uri:
|
||||
shutil.rmtree(Path(input_uri).parent, ignore_errors=True)
|
||||
shutil.rmtree(self.storage_dir / "runs" / run_id, ignore_errors=True)
|
||||
return 1
|
||||
Reference in New Issue
Block a user