fix: 保持产物收尾幂等并保护批量成品完整性
This commit is contained in:
+11
-6
@@ -48,6 +48,7 @@ from wov_app.config import BATCH_INTERVAL_SECONDS, STORAGE_DIR
|
||||
from wov_app.db import Database
|
||||
from wov_app.logging import get_logger
|
||||
from wov_app.scheduler import WorkflowScheduler
|
||||
from wov_app.storage import atomic_copy
|
||||
from wov_sdk.models import WorkflowDefinition
|
||||
|
||||
# 批量引擎运行日志:任务进度、视频逐个处理与暂停/续跑等状态变化。
|
||||
@@ -531,18 +532,22 @@ class BatchWorker:
|
||||
"已有字幕"规则跳过该视频。同名目标直接覆盖:可能是上一次运行/旧工作流
|
||||
留下的旧内容,应以本次产物为准。
|
||||
"""
|
||||
placed: list[str] = []
|
||||
video.parent.mkdir(parents=True, exist_ok=True)
|
||||
# 先校验全部必需输出,缺文件时不覆盖任何视频旁成品,更不能继续清理。
|
||||
products: list[tuple[Path, Path]] = []
|
||||
for alias in definition.final_outputs:
|
||||
artifact = self.db.get_artifact(run_id, alias)
|
||||
if artifact is None:
|
||||
continue
|
||||
raise ValueError(f"missing final artifact: {alias}")
|
||||
source = Path(artifact["uri"])
|
||||
if not source.is_file():
|
||||
continue
|
||||
raise ValueError(f"missing final artifact file: {alias} ({source})")
|
||||
target = video.parent / _sidecar_product_name(video, source)
|
||||
# 目标名稳定 → 直接覆盖写入,避免旧同名产物被"大小一致复用"误保留。
|
||||
shutil.copy2(source, target)
|
||||
products.append((source, target))
|
||||
placed: list[str] = []
|
||||
for source, target in products:
|
||||
# 稳定目标名 + 原子替换:复制失败保留旧成品,异常交调用方记录 FAILED。
|
||||
# 多文件中途失败仍保留完整工作空间,下次可以幂等地重新放置。
|
||||
atomic_copy(source, target)
|
||||
placed.append(target.name)
|
||||
return placed
|
||||
|
||||
|
||||
+35
-22
@@ -18,6 +18,7 @@ from wov_app import registry
|
||||
from wov_app.config import SCHEDULER_INTERVAL_SECONDS
|
||||
from wov_app.db import Database
|
||||
from wov_app.logging import get_logger
|
||||
from wov_app.storage import atomic_copy
|
||||
|
||||
# 调度器运行日志:节点进度、暂停/续跑等状态变化。
|
||||
logger = get_logger("scheduler")
|
||||
@@ -237,19 +238,21 @@ class WorkflowScheduler:
|
||||
# 处理 final_outputs,为用户端提供简洁的下载别名。
|
||||
for alias, ref in definition.final_outputs.items():
|
||||
resolved = self._resolve_ref(ref, run.get("input_uri"), outputs_by_node)
|
||||
if resolved is not None:
|
||||
# 最终产物按 上传文件名.标识.时间戳 重命名,区分语言与版本。
|
||||
resolved = self._final_artifact_uri(resolved, run, definition, alias, ref)
|
||||
self.db.create_artifact(
|
||||
{
|
||||
"run_id": run_id,
|
||||
"node_id": ref.partition(".")[0],
|
||||
"name": alias,
|
||||
"uri": resolved,
|
||||
"mime_type": self._mime_type(resolved),
|
||||
"size": self._file_size(resolved),
|
||||
}
|
||||
)
|
||||
if resolved is None:
|
||||
raise ValueError(f"missing final output: {alias} ({ref})")
|
||||
# 成品按 上传文件名.标识.时间戳 命名,保留节点原始文件与 URI。
|
||||
# 同一个 run 重复收尾使用稳定路径,不再因暂停/重启反复改名。
|
||||
resolved = self._final_artifact_uri(resolved, run, definition, alias, ref)
|
||||
self.db.create_artifact(
|
||||
{
|
||||
"run_id": run_id,
|
||||
"node_id": ref.partition(".")[0],
|
||||
"name": alias,
|
||||
"uri": resolved,
|
||||
"mime_type": self._mime_type(resolved),
|
||||
"size": self._file_size(resolved),
|
||||
}
|
||||
)
|
||||
|
||||
# 全部节点成功后标记完成;期间被暂停则保持 PAUSED,等待续跑补做收尾。
|
||||
if self.db.get_run(run_id)["status"] == "PAUSED":
|
||||
@@ -285,25 +288,35 @@ class WorkflowScheduler:
|
||||
alias: str,
|
||||
ref: str,
|
||||
) -> str:
|
||||
"""把最终产物重命名为 上传文件名.标识.时间戳 并返回新 URI。
|
||||
"""生成命名成品副本并返回稳定 URI,节点原始产物始终保留。
|
||||
|
||||
标识优先取产出节点的 target_language 参数(如 zh-CN),否则回退为
|
||||
产物别名;时间戳取当前时刻,用于区分同一上传文件的多次运行版本。
|
||||
重命名在原地进行(同目录),不复制文件。
|
||||
标识优先取产出节点的 target_language,否则回退别名;时间戳固定为
|
||||
run 创建时间。每个别名单独目录,防止相同语言/扩展名的输出互相覆盖。
|
||||
复制使用原子替换;收尾重复执行覆盖相同目标,不生成新的时间戳副本。
|
||||
"""
|
||||
source = Path(resolved)
|
||||
# 续跑等场景下源文件可能已被上次收尾重命名过:不再重命名,原样返回。
|
||||
if not source.is_file():
|
||||
return resolved
|
||||
# 兼容旧版本:原文件已改名,但最终别名仍记录有效路径时直接复用。
|
||||
existing = self.db.get_artifact(run["id"], alias)
|
||||
if existing is not None and Path(existing["uri"]).is_file():
|
||||
return existing["uri"]
|
||||
raise ValueError(f"missing final output file: {alias} ({resolved})")
|
||||
# 基础名来自上传文件名;无上传文件时退回通用名称 subtitle。
|
||||
base = Path(run["input_uri"]).stem if run.get("input_uri") else "subtitle"
|
||||
# 通过最终输出引用定位产出节点,取其语言参数作为标识。
|
||||
node_id = ref.partition(".")[0]
|
||||
node = next((item for item in definition.nodes if item.id == node_id), None)
|
||||
tag = (node.params.get("target_language") if node else None) or alias
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
||||
new_path = source.with_name(f"{base}.{tag}.{timestamp}{source.suffix}")
|
||||
source.rename(new_path)
|
||||
# 编码目录别名避免路径分隔符;保留常规 cn_srt/ass 名称便于排查。
|
||||
from urllib.parse import quote
|
||||
|
||||
timestamp = datetime.fromisoformat(run["created_at"]).strftime("%Y%m%d%H%M%S")
|
||||
final_dir = self.storage_dir / "runs" / run["id"] / "finals" / ("output-" + quote(alias, safe=""))
|
||||
filename = f"{base}.{tag}.{timestamp}{source.suffix}"
|
||||
if Path(filename).name != filename or "\\" in filename:
|
||||
raise ValueError(f"invalid final output filename: {alias}")
|
||||
new_path = final_dir / filename
|
||||
atomic_copy(source, new_path)
|
||||
return str(new_path)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""产物文件操作:以同目录临时文件复制并原子替换,避免暴露写到一半的成品。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def atomic_copy(source: Path, target: Path) -> None:
|
||||
"""保留源文件,完整复制后替换目标;失败清理临时文件并保留原目标。
|
||||
|
||||
临时文件与目标位于同一目录,确保 replace 不跨文件系统;关闭句柄后再
|
||||
copy2/replace,兼容 Windows。原子性针对单个文件,不承诺多文件事务或
|
||||
断电持久性;调用方仅在全部必需文件复制成功后登记完成并清理工作空间。
|
||||
"""
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
dir=target.parent, prefix=f".{target.name}.", suffix=".tmp", delete=False
|
||||
) as handle:
|
||||
temporary = Path(handle.name)
|
||||
try:
|
||||
shutil.copy2(source, temporary)
|
||||
temporary.replace(target)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
Reference in New Issue
Block a user