批量流水线会在多个线程里同时写产物与进度,默认的回滚日志模式与 5 秒 busy timeout 下容易出现 database is locked。 - 连接统一带 `WOV_DB_BUSY_TIMEOUT_SECONDS`(默认 30 秒)等待写锁。 - 打开 WAL 与 `synchronous=NORMAL`:读写并行,落盘开销更低;不支持 WAL 的 文件系统会保持原模式(SQLite 自身降级)。 - 测试:断言 WAL 生效,并用 4 线程 × 25 条并发写产物验证不丢数据、不报错。
443 lines
17 KiB
Python
443 lines
17 KiB
Python
"""src/wov_app/db.py 的模块级测试(数据 → 测试过程 → 验证结果)。
|
||
|
||
被测模块:`src/wov_app/db.py`(SQLite Repository:工作流/版本/任务/产物/
|
||
批量任务),可独立调用。每个用例在临时目录创建独立数据库文件(真实 SQLite),
|
||
不依赖全局 conftest。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from wov_app.db import Database
|
||
|
||
|
||
@pytest.fixture()
|
||
def db(tmp_path: Path) -> Database:
|
||
"""每个用例一个独立 SQLite 库(真实文件,非内存桩)。"""
|
||
return Database(tmp_path / "wov.db")
|
||
|
||
|
||
@pytest.fixture()
|
||
def db_with_workflow(db: Database) -> Database:
|
||
"""已建好工作流(含 v1 版本)的库:任务表对 workflow_id 有外键约束。"""
|
||
db.upsert_workflow(_workflow("wf"))
|
||
db.create_workflow_version("wf", 1, {"nodes": []})
|
||
return db
|
||
|
||
|
||
def _workflow(workflow_id: str = "wf", name: str = "流程") -> dict:
|
||
"""构造真实工作流记录字段。"""
|
||
return {"id": workflow_id, "name": name, "description": ""}
|
||
|
||
|
||
def _run(run_id: str = "run-1", **overrides) -> dict:
|
||
"""构造真实任务记录字段(默认 upload 来源、QUEUED 状态)。"""
|
||
record = {
|
||
"id": run_id,
|
||
"workflow_id": "wf",
|
||
"workflow_version": 1,
|
||
"status": "QUEUED",
|
||
"current_node_id": None,
|
||
"progress": 0.0,
|
||
"error": None,
|
||
"input_uri": None,
|
||
"param_overrides": None,
|
||
"source": "upload",
|
||
"created_at": "2026-09-01T00:00:00+00:00",
|
||
"updated_at": "2026-09-01T00:00:00+00:00",
|
||
}
|
||
record.update(overrides)
|
||
return record
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 工作流与版本
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_upsert_and_get_workflow(db: Database) -> None:
|
||
"""工作流写入后可读回,重复写入同 ID 覆盖而不报错。"""
|
||
# 数据:一条工作流记录。
|
||
db.upsert_workflow(_workflow("wf-1", "初版"))
|
||
|
||
# 测试过程
|
||
stored = db.get_workflow("wf-1")
|
||
db.upsert_workflow(_workflow("wf-1", "改名"))
|
||
renamed = db.get_workflow("wf-1")
|
||
|
||
# 验证结果
|
||
assert stored["name"] == "初版"
|
||
assert renamed["name"] == "改名"
|
||
|
||
|
||
def test_list_and_delete_workflow(db: Database) -> None:
|
||
"""列出全部工作流;删除后不再出现在列表与查询中。"""
|
||
# 数据:两条工作流。
|
||
db.upsert_workflow(_workflow("wf-1"))
|
||
db.upsert_workflow(_workflow("wf-2"))
|
||
|
||
# 测试过程
|
||
before = {w["id"] for w in db.list_workflows()}
|
||
db.delete_workflow("wf-1")
|
||
after = {w["id"] for w in db.list_workflows()}
|
||
|
||
# 验证结果
|
||
assert before == {"wf-1", "wf-2"}
|
||
assert after == {"wf-2"}
|
||
assert db.get_workflow("wf-1") is None
|
||
|
||
|
||
def test_workflow_versions_and_latest(db: Database) -> None:
|
||
"""版本按序保存,latest 返回最高版本,可按版本号精确读取。"""
|
||
# 数据:同一工作流的 v1 与 v2 定义。
|
||
db.upsert_workflow(_workflow("wf-1"))
|
||
db.create_workflow_version("wf-1", 1, {"nodes": [{"id": "a"}]})
|
||
db.create_workflow_version("wf-1", 2, {"nodes": [{"id": "a"}, {"id": "b"}]})
|
||
|
||
# 测试过程
|
||
latest = db.get_latest_workflow_version("wf-1")
|
||
first = db.get_workflow_version("wf-1", 1)
|
||
versions = db.list_workflow_versions("wf-1")
|
||
|
||
# 验证结果
|
||
assert latest["version"] == 2
|
||
assert len(latest["definition"]["nodes"]) == 2
|
||
assert first["definition"]["nodes"] == [{"id": "a"}]
|
||
assert [v["version"] for v in versions] == [2, 1]
|
||
|
||
|
||
def test_workflow_version_missing_returns_none(db: Database) -> None:
|
||
"""不存在的工作流/版本返回 None(不做隐式创建)。"""
|
||
# 数据:空库。
|
||
# 测试过程与验证结果
|
||
assert db.get_latest_workflow_version("nope") is None
|
||
assert db.get_workflow_version("nope", 1) is None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 任务:创建、读写、param_overrides
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_create_and_get_run_with_overrides(db_with_workflow: Database) -> None:
|
||
"""任务创建后可读回,param_overrides 以 JSON 存储并解析回字典。"""
|
||
# 数据:带参数覆盖的 upload 任务。
|
||
db_with_workflow.create_run(_run("r1", param_overrides={"frame-extract": {"crop": [0, 0.75, 1, 0.25]}}))
|
||
|
||
# 测试过程
|
||
stored = db_with_workflow.get_run("r1")
|
||
|
||
# 验证结果
|
||
assert stored["status"] == "QUEUED"
|
||
assert stored["source"] == "upload"
|
||
assert stored["param_overrides"] == {"frame-extract": {"crop": [0, 0.75, 1, 0.25]}}
|
||
|
||
|
||
def test_update_run_fields(db_with_workflow: Database) -> None:
|
||
"""update_run 按字段更新(状态/进度/错误)。"""
|
||
# 数据:一条任务。
|
||
db_with_workflow.create_run(_run("r1"))
|
||
|
||
# 测试过程
|
||
db_with_workflow.update_run("r1", status="RUNNING", progress=0.5, error=None, updated_at="t2")
|
||
stored = db_with_workflow.get_run("r1")
|
||
|
||
# 验证结果
|
||
assert stored["status"] == "RUNNING"
|
||
assert stored["progress"] == 0.5
|
||
|
||
|
||
def test_list_runs_orders_by_created_at_desc(db_with_workflow: Database) -> None:
|
||
"""任务列表按创建时间倒序返回(新的在前)。"""
|
||
# 数据:三条不同创建时间的任务。
|
||
db_with_workflow.create_run(_run("old", created_at="2026-09-01T00:00:00+00:00"))
|
||
db_with_workflow.create_run(_run("mid", created_at="2026-09-02T00:00:00+00:00"))
|
||
db_with_workflow.create_run(_run("new", created_at="2026-09-03T00:00:00+00:00"))
|
||
|
||
# 测试过程
|
||
ids = [r["id"] for r in db_with_workflow.list_runs()]
|
||
|
||
# 验证结果
|
||
assert ids == ["new", "mid", "old"]
|
||
|
||
|
||
def test_list_runs_excludes_batch_runs_by_default(db_with_workflow: Database) -> None:
|
||
"""任务列表默认不含批量 run:它们属于批量页的任务明细,会把 20 条窗口占满。"""
|
||
# 数据:一条上传任务 + 两条批量 run。
|
||
db_with_workflow.create_run(_run("upload", created_at="2026-09-01T00:00:00+00:00"))
|
||
db_with_workflow.create_run(_run("batch-1", source="batch", created_at="2026-09-02T00:00:00+00:00"))
|
||
db_with_workflow.create_run(_run("batch-2", source="batch", created_at="2026-09-03T00:00:00+00:00"))
|
||
|
||
# 测试过程
|
||
default_ids = [r["id"] for r in db_with_workflow.list_runs()]
|
||
all_ids = [r["id"] for r in db_with_workflow.list_runs(include_batch=True)]
|
||
|
||
# 验证结果:默认只列上传任务,显式要求时才包含批量 run。
|
||
assert default_ids == ["upload"]
|
||
assert all_ids == ["batch-2", "batch-1", "upload"]
|
||
|
||
|
||
def test_delete_run_removes_record_and_artifacts(db_with_workflow: Database) -> None:
|
||
"""删除任务同时清理其产物记录。"""
|
||
# 数据:任务 + 一条产物。
|
||
db_with_workflow.create_run(_run("r1"))
|
||
db_with_workflow.create_artifact({
|
||
"run_id": "r1", "node_id": "a", "name": "a.data_uri", "uri": "/tmp/x", "kind": "file",
|
||
})
|
||
|
||
# 测试过程
|
||
db_with_workflow.delete_run("r1")
|
||
|
||
# 验证结果
|
||
assert db_with_workflow.get_run("r1") is None
|
||
assert db_with_workflow.list_artifacts("r1") == []
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 调度用查询:只有 QUEUED 会被拾起
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_next_queued_run_returns_oldest_queued(db_with_workflow: Database) -> None:
|
||
"""按创建时间返回最早的 QUEUED 任务。"""
|
||
# 数据:一条较早的 QUEUED。
|
||
db_with_workflow.create_run(_run("a", created_at="2026-09-01T00:00:00+00:00"))
|
||
|
||
# 测试过程
|
||
picked = db_with_workflow.next_queued_run()
|
||
|
||
# 验证结果
|
||
assert picked["id"] == "a"
|
||
|
||
|
||
def test_next_queued_run_skips_paused(db_with_workflow: Database) -> None:
|
||
"""PAUSED 不被拾起(必须显式 resume;修复"点击暂停反而开始任务"回归)。"""
|
||
# 数据:一条 PAUSED(更早)+ 一条 QUEUED(更晚)。
|
||
db_with_workflow.create_run(_run("paused", status="PAUSED", created_at="2026-09-01T00:00:00+00:00"))
|
||
db_with_workflow.create_run(_run("queued", created_at="2026-09-02T00:00:00+00:00"))
|
||
|
||
# 测试过程
|
||
picked = db_with_workflow.next_queued_run()
|
||
|
||
# 验证结果:取的是 QUEUED 那条。
|
||
assert picked["id"] == "queued"
|
||
|
||
|
||
def test_next_queued_run_skips_batch_source(db_with_workflow: Database) -> None:
|
||
"""source=batch 的任务由批量引擎执行,主调度器不拾起(存储目录不同)。"""
|
||
# 数据:一条 batch 来源的 QUEUED。
|
||
db_with_workflow.create_run(_run("batch-1", source="batch"))
|
||
|
||
# 测试过程与验证结果
|
||
assert db_with_workflow.next_queued_run() is None
|
||
|
||
|
||
def test_pause_and_resume_run(db_with_workflow: Database) -> None:
|
||
"""暂停置 PAUSED,继续置回 QUEUED(等待调度器断点续跑)。"""
|
||
# 数据:一条 QUEUED。
|
||
db_with_workflow.create_run(_run("r1"))
|
||
|
||
# 测试过程
|
||
db_with_workflow.pause_run("r1", "t2")
|
||
paused = db_with_workflow.get_run("r1")["status"]
|
||
db_with_workflow.resume_run("r1", "t3")
|
||
resumed = db_with_workflow.get_run("r1")["status"]
|
||
|
||
# 验证结果
|
||
assert paused == "PAUSED"
|
||
assert resumed == "QUEUED"
|
||
|
||
|
||
def test_recover_interrupted_runs_requeues_running_only(db_with_workflow: Database) -> None:
|
||
"""重启恢复:RUNNING → QUEUED,PAUSED 保持不变。"""
|
||
# 数据:RUNNING 与 PAUSED 各一条。
|
||
db_with_workflow.create_run(_run("running", status="RUNNING"))
|
||
db_with_workflow.create_run(_run("paused", status="PAUSED"))
|
||
|
||
# 测试过程
|
||
count = db_with_workflow.recover_interrupted_runs("t2")
|
||
|
||
# 验证结果:只恢复 1 条,PAUSED 不变。
|
||
assert count == 1
|
||
assert db_with_workflow.get_run("running")["status"] == "QUEUED"
|
||
assert db_with_workflow.get_run("paused")["status"] == "PAUSED"
|
||
|
||
|
||
def test_recover_interrupted_batch_jobs_requeues_running(db_with_workflow: Database) -> None:
|
||
"""重启恢复:RUNNING 的批量任务 → QUEUED(否则永久无人拾起)。"""
|
||
# 数据:一条 RUNNING 批量任务(批量明细表对 run 有外键,先建任务记录)。
|
||
db_with_workflow.create_run(_run("bv-run-1"))
|
||
db_with_workflow.create_batch_job({
|
||
"id": "job-1", "folder_path": "/videos", "workflow_id": "wf", "recursive": False,
|
||
"status": "RUNNING", "created_at": "t1", "updated_at": "t1",
|
||
})
|
||
|
||
# 测试过程
|
||
count = db_with_workflow.recover_interrupted_batch_jobs("t2")
|
||
|
||
# 验证结果
|
||
assert count == 1
|
||
assert db_with_workflow.get_batch_job("job-1")["status"] == "QUEUED"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 产物与断点恢复
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_create_and_get_artifact(db_with_workflow: Database) -> None:
|
||
"""产物按 run + name 记录,可按名精确读取。"""
|
||
# 数据:一条任务 + 一条产物(产物对 run 有外键)。
|
||
db_with_workflow.create_run(_run("r1"))
|
||
db_with_workflow.create_artifact({
|
||
"run_id": "r1", "node_id": "ocr", "name": "ocr.srt_uri",
|
||
"uri": "/tmp/out/subtitle.srt", "kind": "file",
|
||
})
|
||
|
||
# 测试过程
|
||
stored = db_with_workflow.get_artifact("r1", "ocr.srt_uri")
|
||
|
||
# 验证结果
|
||
assert stored["uri"] == "/tmp/out/subtitle.srt"
|
||
assert db_with_workflow.get_artifact("r1", "missing") is None
|
||
|
||
|
||
def test_restore_run_outputs_strips_node_prefix(db_with_workflow: Database) -> None:
|
||
"""恢复产物时剥去"节点ID."前缀,还原为 {输出名: URI}(断点续跑依赖)。"""
|
||
# 数据:两个节点的产物。
|
||
db_with_workflow.create_run(_run("r1"))
|
||
db_with_workflow.create_artifact({"run_id": "r1", "node_id": "extract", "name": "extract.audio_uri", "uri": "/a.wav", "kind": "file"})
|
||
db_with_workflow.create_artifact({"run_id": "r1", "node_id": "asr", "name": "asr.srt_uri", "uri": "/a.srt", "kind": "file"})
|
||
|
||
# 测试过程
|
||
outputs = db_with_workflow.restore_run_outputs("r1")
|
||
|
||
# 验证结果
|
||
assert outputs == {
|
||
"extract": {"audio_uri": "/a.wav"},
|
||
"asr": {"srt_uri": "/a.srt"},
|
||
}
|
||
|
||
|
||
def test_reset_run_clears_state_and_artifacts(db_with_workflow: Database) -> None:
|
||
"""reset_run 清空产物与错误、回到 QUEUED(供失败任务重跑)。"""
|
||
# 数据:一条 FAILED 任务带产物与错误。
|
||
db_with_workflow.create_run(_run("r1", status="FAILED", error="boom"))
|
||
db_with_workflow.create_artifact({"run_id": "r1", "node_id": "a", "name": "a.x", "uri": "/x", "kind": "file"})
|
||
|
||
# 测试过程
|
||
db_with_workflow.reset_run("r1", "t2")
|
||
stored = db_with_workflow.get_run("r1")
|
||
|
||
# 验证结果
|
||
assert stored["status"] == "QUEUED"
|
||
assert stored["error"] is None
|
||
assert db_with_workflow.list_artifacts("r1") == []
|
||
|
||
|
||
def test_list_run_ids(db_with_workflow: Database) -> None:
|
||
"""列出全部任务 ID(供孤儿清理比对文件系统)。"""
|
||
# 数据:两条任务。
|
||
db_with_workflow.create_run(_run("r1"))
|
||
db_with_workflow.create_run(_run("r2"))
|
||
|
||
# 测试过程与验证结果
|
||
assert sorted(db_with_workflow.list_run_ids()) == ["r1", "r2"]
|
||
|
||
|
||
def test_recover_interrupted_batch_jobs_requeues_zombie_completed(db_with_workflow: Database) -> None:
|
||
"""重启恢复:COMPLETED 但仍有未结束视频的僵尸任务也要置回 QUEUED。
|
||
|
||
曾出现「任务已完成、视频仍未处理」的僵尸状态:完成标记先于视频收尾写出,
|
||
而引擎只拾取 QUEUED,剩余视频永久无人处理。
|
||
"""
|
||
# 数据:一个仍含 PENDING 视频的 COMPLETED 任务 + 一个全部结束的 COMPLETED 任务。
|
||
db_with_workflow.create_batch_job({
|
||
"id": "zombie", "folder_path": "/videos", "workflow_id": "wf", "recursive": False,
|
||
"status": "COMPLETED", "created_at": "t1", "updated_at": "t1",
|
||
})
|
||
db_with_workflow.create_batch_video({
|
||
"id": "zombie-v1", "job_id": "zombie", "video_path": "/videos/a.mp4",
|
||
"work_dir": "/tmp/zombie", "status": "PENDING",
|
||
"created_at": "t1", "updated_at": "t1",
|
||
})
|
||
db_with_workflow.create_batch_job({
|
||
"id": "done", "folder_path": "/videos", "workflow_id": "wf", "recursive": False,
|
||
"status": "COMPLETED", "created_at": "t1", "updated_at": "t1",
|
||
})
|
||
db_with_workflow.create_batch_video({
|
||
"id": "done-v1", "job_id": "done", "video_path": "/videos/b.mp4",
|
||
"work_dir": "/tmp/done", "status": "COMPLETED",
|
||
"created_at": "t1", "updated_at": "t1",
|
||
})
|
||
|
||
# 测试过程
|
||
count = db_with_workflow.recover_interrupted_batch_jobs("t2")
|
||
|
||
# 验证结果:只有僵尸任务被置回 QUEUED,真正完成的任务不受影响。
|
||
assert count == 1
|
||
assert db_with_workflow.get_batch_job("zombie")["status"] == "QUEUED"
|
||
assert db_with_workflow.get_batch_job("done")["status"] == "COMPLETED"
|
||
|
||
|
||
def test_wal_mode_enabled_for_concurrent_pipeline_writes(tmp_path: Path) -> None:
|
||
"""数据:临时库文件。
|
||
|
||
过程:打开一次连接并读取日志模式。
|
||
|
||
验证:处于 WAL 模式——批量流水线多线程并发写产物/进度时,读写可并行,
|
||
不会互相阻塞成 database is locked。
|
||
"""
|
||
db = Database(tmp_path / "wov.db")
|
||
|
||
with db._connect() as conn:
|
||
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
|
||
|
||
assert mode.lower() == "wal"
|
||
|
||
|
||
def test_concurrent_artifact_writes_survive(tmp_path: Path) -> None:
|
||
"""数据:四个线程各自往同一任务写入 25 条产物记录。
|
||
|
||
过程:并发调用 create_artifact(批量流水线里多个视频同时落产物的场景)。
|
||
|
||
验证:全部写入成功且记录数正确,没有因写锁竞争丢数据或抛异常。
|
||
"""
|
||
import threading
|
||
|
||
db = Database(tmp_path / "wov.db")
|
||
db.upsert_workflow({"id": "wf", "name": "流程", "description": "", "published": 1, "latest_version": 1})
|
||
db.create_workflow_version("wf", 1, {"nodes": []})
|
||
db.create_run({
|
||
"id": "run-concurrent", "workflow_id": "wf", "workflow_version": 1,
|
||
"status": "RUNNING", "source": "batch", "created_at": "t1", "updated_at": "t1",
|
||
})
|
||
errors: list[Exception] = []
|
||
|
||
def writer(worker: int) -> None:
|
||
try:
|
||
for index in range(25):
|
||
db.create_artifact({
|
||
"run_id": "run-concurrent", "node_id": "asr",
|
||
"name": f"w{worker}-{index}", "uri": f"/tmp/w{worker}-{index}.srt",
|
||
"mime_type": "text/plain", "size": index,
|
||
})
|
||
except Exception as exc: # noqa: BLE001 - 用例要报告任意写失败
|
||
errors.append(exc)
|
||
|
||
threads = [threading.Thread(target=writer, args=(n,)) for n in range(4)]
|
||
for thread in threads:
|
||
thread.start()
|
||
for thread in threads:
|
||
thread.join()
|
||
|
||
assert errors == []
|
||
with db._connect() as conn:
|
||
count = conn.execute(
|
||
"SELECT COUNT(*) FROM artifacts WHERE run_id = ?", ("run-concurrent",),
|
||
).fetchone()[0]
|
||
assert count == 100
|