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
+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