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
+26 -10
View File
@@ -682,13 +682,16 @@ def test_batch_worker_pending_leftover_never_marks_completed(tmp_path, monkeypat
monkeypatch.setattr("wov_app.batch.WorkflowScheduler", _CrashScheduler)
# 放开假调度器的崩溃:第二次调用时不再删 run,让真实调度器跑通。
class _RecoveringScheduler:
"""第二轮:不再崩溃,把 run 置 COMPLETED由引擎收尾放产物)"""
"""第二轮:真实执行工作流并产生成品,由引擎收尾放"""
def __init__(self, db: Database, work_dir: Path) -> None:
self.db = db
def execute_run(self, run_id: str) -> None:
self.db.update_run(run_id, status="COMPLETED", updated_at=_now_iso())
from wov_app.scheduler import WorkflowScheduler
video = self.db.list_batch_videos(job["id"])[0]
WorkflowScheduler(self.db, Path(video["work_dir"])).execute_run(run_id)
monkeypatch.setattr("wov_app.batch.WorkflowScheduler", _RecoveringScheduler)
worker._process_job(job)
@@ -701,7 +704,7 @@ def test_batch_worker_already_completed_runs_finalized(tmp_path) -> None:
"""run 已完成但视频未标记(收尾前中断):直接放置产物、清理并标记完成。
覆盖 _place_products 各分支:产物齐全(复制)、产物记录存在但文件丢失
跳过)、无产物记录(跳过)——完成后均清理工作空间与 run 记录。
报错)、无产物记录(报错)——只有成品齐全才清理工作空间与 run 记录。
"""
db = _db(tmp_path)
_seed_echo_workflow(db)
@@ -714,6 +717,7 @@ def test_batch_worker_already_completed_runs_finalized(tmp_path) -> None:
"""创建 COMPLETED run;可选产物文件与产物记录。"""
_create_run(db, run_id, folder, status="COMPLETED", name=name)
work_dir = Path(videos[name]["work_dir"])
work_dir.mkdir(parents=True)
if with_artifact:
path = work_dir / "runs" / run_id / "steps" / "step" / f"{name}.result.txt"
db.create_artifact(
@@ -739,20 +743,24 @@ def test_batch_worker_already_completed_runs_finalized(tmp_path) -> None:
BatchWorker(db, interval_seconds=0.05)._process_job(db.get_batch_job(job["id"]))
videos = {Path(v["video_path"]).stem: v for v in db.list_batch_videos(job["id"])}
assert all(videos[name]["status"] == "COMPLETED" for name in ("a", "b", "c"))
assert videos["a"]["status"] == "COMPLETED"
assert all(videos[name]["status"] == "FAILED" for name in ("b", "c"))
# a 的产物复制到视频旁;b/c 无产物可复制。
product = _find_product(folder, "a", "result")
assert product.read_text(encoding="utf-8") == "产物 a"
assert not list(folder.glob("b.result.*")) and not list(folder.glob("c.result.*"))
# 三个视频的工作空间 run 记录都被清理
for name in ("a", "b", "c"):
assert not Path(videos[name]["work_dir"]).exists()
assert db.get_run(f"run_done_{name}") is None
assert db.list_runs() == []
# a 清理完成;b/c 必须保留工作空间、关联 run 与错误供修复后重试
assert not Path(videos["a"]["work_dir"]).exists()
assert db.get_run("run_done_a") is None
for name in ("b", "c"):
assert Path(videos[name]["work_dir"]).exists()
assert db.get_run(f"run_done_{name}") is not None
assert videos[name]["run_id"] == f"run_done_{name}"
assert "result" in videos[name]["error"]
def test_batch_worker_place_products_branches(tmp_path) -> None:
"""_place_products:无产物记录/文件丢失跳过;.srt/.ass 按库内约定命名覆盖;
"""_place_products:无产物记录/文件丢失报错;.srt/.ass 按库内约定命名覆盖;
其他扩展名保留原文件名复制。"""
db = _db(tmp_path)
_seed_echo_workflow(db)
@@ -805,6 +813,14 @@ def test_batch_worker_place_products_branches(tmp_path) -> None:
{"run_id": run_id, "node_id": "step", "name": "alias_txt", "uri": str(src_txt), "mime_type": "text/plain", "size": src_txt.stat().st_size}
)
# 所有必需成品预检通过前不开始覆盖,缺失原因中包含具体别名。
with pytest.raises(ValueError, match="alias_none"):
worker._place_products(run_id, video, WorkflowDefinition.from_dict(definition))
del definition["final_outputs"]["alias_none"]
with pytest.raises(ValueError, match="alias_lost"):
worker._place_products(run_id, video, WorkflowDefinition.from_dict(definition))
assert target_srt.read_text(encoding="utf-8") == "旧字幕内容"
del definition["final_outputs"]["alias_lost"]
placed = worker._place_products(run_id, video, WorkflowDefinition.from_dict(definition))
assert placed == ["a.CN.srt", "a.CN_dual_eye.ass", "a.other.20260902120000.txt"]
# srt/ass 被改名为标准名且覆盖旧文件;txt 保留原名。
+169
View File
@@ -0,0 +1,169 @@
"""产物收尾回归:真实 SQLite、字幕文件和 API 下载验证暂停及故障恢复。"""
import shutil
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from wov_app.db import Database
from wov_app.main import app
from wov_app.scheduler import WorkflowScheduler
from wov_sdk.models import WorkflowDefinition
@pytest.fixture
def subtitle(tmp_path):
"""复用真实字幕资产,不在测试中生成模型输出或占位媒体。"""
source = Path(__file__).resolve().parent.parent / "testdata/ocr_srt_run_ac7f480a3ccb.srt"
if not source.is_file():
pytest.skip("缺少真实字幕资产")
target = tmp_path / "original.srt"
shutil.copy2(source, target)
return target
def _seed(db, source, finals):
"""登记已完成节点的真实产物,模拟节点执行结束后的断点。"""
definition = {"name": "finalization", "version": 1,
"nodes": [{"id": "step", "node_type": "echo"}],
"final_outputs": finals}
db.upsert_workflow({"id": "finalization", "name": "收尾回归"})
db.create_workflow_version("finalization", 1, definition)
db.create_run({"id": "run_finalization", "workflow_id": "finalization",
"workflow_version": 1, "status": "QUEUED", "input_uri": str(source),
"created_at": "2026-01-01T00:00:00+00:00", "updated_at": "2026-01-01T00:00:00+00:00"})
db.create_artifact({"run_id": "run_finalization", "node_id": "step",
"name": "step.file_uri", "uri": str(source)})
return WorkflowDefinition.from_dict(definition)
def test_pause_during_final_registration_keeps_downloads(subtitle, tmp_path, monkeypatch):
"""在第一个最终别名登记后暂停,再继续,两别名均可下载且 URI 不漂移。"""
db = Database(tmp_path / "test.db")
_seed(db, subtitle, {"result": "step.file_uri", "copy": "step.file_uri"})
original = subtitle.read_bytes()
create = db.create_artifact
def pause_once(artifact):
create(artifact)
if artifact["name"] == "result":
db.pause_run("run_finalization", "2026-01-01T00:00:00+00:00")
scheduler = WorkflowScheduler(db, tmp_path / "storage")
with monkeypatch.context() as patch:
patch.setattr(db, "create_artifact", pause_once)
scheduler.execute_run("run_finalization")
first_uri = db.get_artifact("run_finalization", "result")["uri"]
assert db.get_run("run_finalization")["status"] == "PAUSED"
db.resume_run("run_finalization", "2026-01-01T00:00:00+00:00")
scheduler.execute_run("run_finalization")
assert db.get_run("run_finalization")["status"] == "COMPLETED"
assert db.get_artifact("run_finalization", "result")["uri"] == first_uri
with TestClient(app) as client:
monkeypatch.setattr(app.state, "db", db)
for alias in ("result", "copy", "step.file_uri"):
response = client.get(f"/api/runs/run_finalization/artifacts/{alias}")
assert response.status_code == 200
assert response.content == original
def test_legacy_renamed_final_survives_resume(subtitle, tmp_path):
"""兼容旧版已改名但最终记录有效的断点,恢复不能覆盖成不存在的旧路径。"""
db = Database(tmp_path / "test.db")
_seed(db, subtitle, {"result": "step.file_uri"})
renamed = subtitle.with_name("legacy.srt")
subtitle.rename(renamed)
db.create_artifact({"run_id": "run_finalization", "node_id": "step",
"name": "result", "uri": str(renamed)})
WorkflowScheduler(db, tmp_path / "storage").execute_run("run_finalization")
assert db.get_run("run_finalization")["status"] == "COMPLETED"
assert Path(db.get_artifact("run_finalization", "result")["uri"]).read_bytes() == renamed.read_bytes()
def test_failed_final_copy_preserves_source_and_can_retry(subtitle, tmp_path, monkeypatch):
"""复制中途写入失败不登记残缺成品,源字幕可用,恢复后可成功收尾。"""
db = Database(tmp_path / "test.db")
_seed(db, subtitle, {"result": "step.file_uri"})
expected = subtitle.read_bytes()
scheduler = WorkflowScheduler(db, tmp_path / "storage")
def fail_copy(source, destination, **kwargs):
# 文件 I/O 边界模拟磁盘写满:真实写出截断内容后报错。
Path(destination).write_bytes(Path(source).read_bytes()[:64])
raise OSError("disk full")
with monkeypatch.context() as patch:
patch.setattr(shutil, "copy2", fail_copy)
scheduler.execute_run("run_finalization")
assert db.get_run("run_finalization")["status"] == "FAILED"
assert db.get_artifact("run_finalization", "result") is None
assert subtitle.read_bytes() == expected
assert not list((tmp_path / "storage").rglob("*.tmp"))
db.update_run("run_finalization", status="QUEUED", updated_at="2026-01-01T00:00:00+00:00")
scheduler.execute_run("run_finalization")
assert db.get_run("run_finalization")["status"] == "COMPLETED"
assert Path(db.get_artifact("run_finalization", "result")["uri"]).read_bytes() == expected
def test_batch_copy_failure_preserves_old_product_and_workspace(subtitle, tmp_path, monkeypatch):
"""批量成品写到一半失败:旧字幕不变、run/工作空间保留,重试可收尾。"""
from wov_app.batch import BatchWorker, create_job
video_asset = Path(__file__).resolve().parent.parent / "testdata/subtitle_10s.mp4"
if not video_asset.is_file():
pytest.skip("缺少真实视频资产")
library = tmp_path / "library"
library.mkdir()
video = library / "movie.mp4"
shutil.copy2(video_asset, video)
db = Database(tmp_path / "test.db")
_seed(db, subtitle, {"result": "step.file_uri"})
db.upsert_workflow({"id": "finalization", "name": "收尾回归", "published": 1, "latest_version": 1})
job_id = create_job(db, str(library), "finalization")
item = db.list_batch_videos(job_id)[0]
work_dir = Path(item["work_dir"])
work_dir.mkdir(parents=True)
source = work_dir / "result.srt"
shutil.copy2(subtitle, source)
now = "2026-01-01T00:00:00+00:00"
db.update_batch_video(item["id"], run_id="run_finalization", updated_at=now)
db.update_run("run_finalization", status="COMPLETED", updated_at=now)
db.create_artifact({"run_id": "run_finalization", "node_id": "step",
"name": "result", "uri": str(source)})
target = library / "movie.CN.srt"
shutil.copy2(subtitle, target)
original = target.read_bytes()
worker = BatchWorker(db)
def fail_copy(source, destination, **kwargs):
Path(destination).write_bytes(Path(source).read_bytes()[:64])
raise OSError("disk full")
with monkeypatch.context() as patch:
patch.setattr(shutil, "copy2", fail_copy)
worker._process_job(db.get_batch_job(job_id))
assert db.get_batch_video(item["id"])["status"] == "FAILED"
assert target.read_bytes() == original
assert db.get_run("run_finalization") is not None
assert source.read_bytes() == original
assert not list(library.glob("*.tmp"))
worker._process_job(db.get_batch_job(job_id))
assert db.get_batch_video(item["id"])["status"] == "COMPLETED"
assert db.get_run("run_finalization") is None
assert not work_dir.exists()
assert target.read_bytes() == original
assert video.read_bytes() == video_asset.read_bytes()
@pytest.mark.parametrize("missing_ref", [False, True])
def test_missing_final_fails_run(subtitle, tmp_path, missing_ref):
"""必需最终输出无引用或文件丢失都应 FAILED,不能成功登记失效链接。"""
db = Database(tmp_path / "test.db")
_seed(db, subtitle, {"result": "step.missing" if missing_ref else "step.file_uri"})
if not missing_ref:
subtitle.unlink()
WorkflowScheduler(db, tmp_path / "storage").execute_run("run_finalization")
run = db.get_run("run_finalization")
assert run["status"] == "FAILED"
assert "result" in run["error"]
+2 -2
View File
@@ -429,9 +429,9 @@ def test_final_artifact_renamed_with_language_tag(tmp_path) -> None:
assert filename.startswith("movie01.zh-CN.")
assert filename.endswith(".txt")
assert Path(final["uri"]).is_file()
# 原始未重命名文件不应残留
# 节点原始 URI 必须保持有效;成品副本与原始文本一致,保证断点可恢复
step_artifacts = [item for item in artifacts if item["name"] == "step.file_uri"]
assert not Path(step_artifacts[0]["uri"]).exists()
assert Path(step_artifacts[0]["uri"]).read_bytes() == Path(final["uri"]).read_bytes()
def test_final_artifact_renamed_fallback_base_and_tag(tmp_path) -> None: