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