fix: 保持产物收尾幂等并保护批量成品完整性

This commit is contained in:
2026-09-11 16:12:32 +08:00
parent f3faad0391
commit 3a612919f7
8 changed files with 293 additions and 43 deletions
+11 -6
View File
@@ -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
View File
@@ -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
+26
View File
@@ -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)