- BatchWorker 单线程轮询 batch_jobs 表,处理 source=batch 的运行, 与主调度器互不抢占(next_queued_run 排除 batch 来源) - 直接读取用户所选文件夹下的视频逐个执行流水线,不上传到工作目录; 中间态与产物落在视频旁同名文件夹,batch.done.json 完成标记去重 - 支持暂停/继续、失败容错(单视频失败不阻塞后续)、删除任务只清库 - 孤儿清理跳过 source=batch 运行,防止误删用户视频文件夹 - workflow_runs 新增 source 列(upload/batch),旧库自动迁移
432 lines
15 KiB
Python
432 lines
15 KiB
Python
"""数据库层单元测试。
|
||
|
||
直接对 Database 方法调用真实 SQLite 路径,覆盖工作流、版本、任务与产物的
|
||
增删改查。节点注册表已改为进程内内存态,不再落库。
|
||
"""
|
||
|
||
from pathlib import Path
|
||
|
||
from wov_app.db import Database
|
||
|
||
|
||
def test_workflow_crud(tmp_path) -> None:
|
||
"""验证工作流概要的插入、发布标记更新与删除。"""
|
||
db = Database(tmp_path / "wov.db")
|
||
workflow = {
|
||
"id": "demo",
|
||
"name": "Demo",
|
||
"description": "desc",
|
||
"published": 0,
|
||
"latest_version": 0,
|
||
}
|
||
db.upsert_workflow(workflow)
|
||
assert db.get_workflow("demo")["name"] == "Demo"
|
||
assert [item["id"] for item in db.list_workflows()] == ["demo"]
|
||
|
||
db.upsert_workflow({**workflow, "published": 1, "latest_version": 1})
|
||
assert db.get_workflow("demo")["published"] == 1
|
||
|
||
db.delete_workflow("demo")
|
||
assert db.get_workflow("demo") is None
|
||
|
||
|
||
def test_workflow_versions(tmp_path) -> None:
|
||
"""验证工作流版本的写入、最新版本查询与列表。"""
|
||
db = Database(tmp_path / "wov.db")
|
||
db.upsert_workflow(
|
||
{"id": "demo", "name": "Demo", "published": 1, "latest_version": 2}
|
||
)
|
||
definition = {"name": "Demo", "version": 1, "nodes": [], "edges": []}
|
||
db.create_workflow_version("demo", 1, definition)
|
||
db.create_workflow_version("demo", 2, {**definition, "version": 2})
|
||
|
||
latest = db.get_latest_workflow_version("demo")
|
||
assert latest["version"] == 2
|
||
assert latest["definition"]["version"] == 2
|
||
|
||
version = db.get_workflow_version("demo", 1)
|
||
assert version["version"] == 1
|
||
assert db.get_workflow_version("demo", 99) is None
|
||
assert len(db.list_workflow_versions("demo")) == 2
|
||
|
||
empty_db = Database(tmp_path / "empty.db")
|
||
assert empty_db.get_latest_workflow_version("missing") is None
|
||
|
||
|
||
def test_run_and_artifact_crud(tmp_path) -> None:
|
||
"""验证任务与产物的创建、查询、更新与删除。"""
|
||
db = Database(tmp_path / "wov.db")
|
||
db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1})
|
||
now = "2026-01-01T00:00:00+00:00"
|
||
db.create_run(
|
||
{
|
||
"id": "run_1",
|
||
"workflow_id": "demo",
|
||
"workflow_version": 1,
|
||
"status": "QUEUED",
|
||
"progress": 0,
|
||
"input_uri": "in.txt",
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
}
|
||
)
|
||
assert db.get_run("run_1")["status"] == "QUEUED"
|
||
assert db.next_queued_run()["id"] == "run_1"
|
||
|
||
db.update_run("run_1", status="RUNNING", progress=0.5, updated_at=now)
|
||
db.update_run("run_1")
|
||
assert db.get_run("run_1")["status"] == "RUNNING"
|
||
assert db.get_run("run_1")["progress"] == 0.5
|
||
assert db.next_queued_run() is None
|
||
assert len(db.list_runs()) == 1
|
||
|
||
db.create_artifact(
|
||
{
|
||
"run_id": "run_1",
|
||
"node_id": "echo",
|
||
"name": "result",
|
||
"uri": "out.txt",
|
||
"mime_type": "text/plain",
|
||
"size": 3,
|
||
}
|
||
)
|
||
assert db.get_artifact("run_1", "result")["uri"] == "out.txt"
|
||
assert db.get_artifact("run_1", "missing") is None
|
||
assert len(db.list_artifacts("run_1")) == 1
|
||
|
||
db.delete_run_artifacts("run_1")
|
||
assert db.list_artifacts("run_1") == []
|
||
|
||
|
||
def test_reset_run_clears_error_and_artifacts(tmp_path) -> None:
|
||
"""验证 reset_run 会把失败任务恢复到排队状态并清空旧产物。"""
|
||
db = Database(tmp_path / "wov.db")
|
||
db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1})
|
||
now = "2026-01-01T00:00:00+00:00"
|
||
db.create_run(
|
||
{
|
||
"id": "run_1",
|
||
"workflow_id": "demo",
|
||
"workflow_version": 1,
|
||
"status": "FAILED",
|
||
"progress": 0.75,
|
||
"current_node_id": "translate",
|
||
"error": "timed out",
|
||
"input_uri": "in.txt",
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
}
|
||
)
|
||
db.create_artifact(
|
||
{
|
||
"run_id": "run_1",
|
||
"node_id": "asr",
|
||
"name": "asr.srt_uri",
|
||
"uri": "out.srt",
|
||
"mime_type": "application/x-subrip",
|
||
"size": 3,
|
||
}
|
||
)
|
||
|
||
db.reset_run("run_1", "2026-01-02T00:00:00+00:00")
|
||
|
||
run = db.get_run("run_1")
|
||
assert run["status"] == "QUEUED"
|
||
assert run["progress"] == 0
|
||
assert run["current_node_id"] is None
|
||
assert run["error"] is None
|
||
assert run["updated_at"] == "2026-01-02T00:00:00+00:00"
|
||
assert run["created_at"] == now
|
||
assert db.list_artifacts("run_1") == []
|
||
|
||
|
||
def test_delete_run(tmp_path) -> None:
|
||
"""验证 delete_run 会删除任务记录及其产物记录。"""
|
||
db = Database(tmp_path / "wov.db")
|
||
db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1})
|
||
now = "2026-01-01T00:00:00+00:00"
|
||
db.create_run(
|
||
{
|
||
"id": "run_1",
|
||
"workflow_id": "demo",
|
||
"workflow_version": 1,
|
||
"status": "COMPLETED",
|
||
"progress": 1,
|
||
"input_uri": "in.txt",
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
}
|
||
)
|
||
db.create_artifact(
|
||
{
|
||
"run_id": "run_1",
|
||
"node_id": "asr",
|
||
"name": "asr.srt_uri",
|
||
"uri": "out.srt",
|
||
"mime_type": "application/x-subrip",
|
||
"size": 3,
|
||
}
|
||
)
|
||
db.delete_run("run_1")
|
||
assert db.get_run("run_1") is None
|
||
assert db.list_artifacts("run_1") == []
|
||
|
||
|
||
def test_list_run_ids(tmp_path) -> None:
|
||
"""验证 list_run_ids 返回全部任务 ID,供孤儿清理对照使用。"""
|
||
db = Database(tmp_path / "wov.db")
|
||
db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1})
|
||
now = "2026-01-01T00:00:00+00:00"
|
||
assert db.list_run_ids() == []
|
||
for run_id in ("run_a", "run_b"):
|
||
db.create_run(
|
||
{
|
||
"id": run_id,
|
||
"workflow_id": "demo",
|
||
"workflow_version": 1,
|
||
"status": "QUEUED",
|
||
"progress": 0,
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
}
|
||
)
|
||
assert set(db.list_run_ids()) == {"run_a", "run_b"}
|
||
|
||
|
||
def test_run_param_overrides_persist(tmp_path) -> None:
|
||
"""验证 param_overrides 随任务持久化并可读回。"""
|
||
db = Database(tmp_path / "wov.db")
|
||
db.upsert_workflow({"id": "demo", "name": "D", "published": 1, "latest_version": 1})
|
||
now = "2026-01-01T00:00:00+00:00"
|
||
db.create_run(
|
||
{
|
||
"id": "run_ov",
|
||
"workflow_id": "demo",
|
||
"workflow_version": 1,
|
||
"status": "QUEUED",
|
||
"progress": 0,
|
||
"param_overrides": {"extract": {"crop": [0, 0.5, 1, 0.5]}},
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
}
|
||
)
|
||
run = db.get_run("run_ov")
|
||
assert run["param_overrides"] == {"extract": {"crop": [0, 0.5, 1, 0.5]}}
|
||
assert db.next_queued_run()["param_overrides"] == {"extract": {"crop": [0, 0.5, 1, 0.5]}}
|
||
|
||
|
||
def test_db_migration_adds_param_overrides(tmp_path) -> None:
|
||
"""旧库迁移:缺少 param_overrides 列的库打开后自动补列。"""
|
||
import sqlite3
|
||
|
||
db_path = tmp_path / "old.db"
|
||
conn = sqlite3.connect(db_path)
|
||
conn.execute(
|
||
"CREATE TABLE workflow_runs (id TEXT PRIMARY KEY, workflow_id TEXT NOT NULL,"
|
||
" workflow_version INTEGER NOT NULL, status TEXT NOT NULL, current_node_id TEXT,"
|
||
" progress REAL NOT NULL DEFAULT 0, error TEXT, input_uri TEXT,"
|
||
" created_at TEXT NOT NULL, updated_at TEXT NOT NULL)"
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
Database(db_path)
|
||
conn = sqlite3.connect(db_path)
|
||
columns = [row[1] for row in conn.execute("PRAGMA table_info(workflow_runs)")]
|
||
conn.close()
|
||
assert "param_overrides" in columns
|
||
|
||
|
||
def test_pause_resume_run(tmp_path) -> None:
|
||
"""验证 pause_run/resume_run 的状态流转与 PAUSED 任务不被调度器自动拾起。
|
||
|
||
修复回归:PAUSED 任务若被 next_queued_run 取到,execute_run 会把它复活为
|
||
RUNNING 继续执行——"点击暂停反而开始任务"。暂停必须由用户显式 resume
|
||
(PAUSED → QUEUED)后调度器才重新执行。
|
||
"""
|
||
db = Database(tmp_path / "wov.db")
|
||
db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1})
|
||
now = "2026-01-01T00:00:00+00:00"
|
||
db.create_run(
|
||
{
|
||
"id": "run_p",
|
||
"workflow_id": "demo",
|
||
"workflow_version": 1,
|
||
"status": "QUEUED",
|
||
"progress": 0,
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
}
|
||
)
|
||
db.pause_run("run_p", now)
|
||
assert db.get_run("run_p")["status"] == "PAUSED"
|
||
# 已暂停的任务不会被调度器拾起(等待用户显式 resume)。
|
||
assert db.next_queued_run() is None
|
||
db.resume_run("run_p", now)
|
||
assert db.get_run("run_p")["status"] == "QUEUED"
|
||
assert db.next_queued_run()["id"] == "run_p"
|
||
|
||
|
||
def test_recover_interrupted_runs(tmp_path) -> None:
|
||
"""重启恢复:遗留 RUNNING 任务恢复为 QUEUED(保留产物供断点续跑)。
|
||
|
||
进程被杀/重启时 RUNNING 任务不会自动收尾,若保持 RUNNING 将永久孤儿
|
||
(next_queued_run 不拾起、暂停后又被 execute_run 复活)。恢复为 QUEUED
|
||
后调度器会从产物表断点续跑;用户主动暂停的 PAUSED 任务保持不变。
|
||
"""
|
||
db = Database(tmp_path / "wov.db")
|
||
db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1})
|
||
now = "2026-01-01T00:00:00+00:00"
|
||
for run_id, status in (("run_orphan", "RUNNING"), ("run_paused", "PAUSED"),
|
||
("run_done", "COMPLETED")):
|
||
db.create_run(
|
||
{
|
||
"id": run_id,
|
||
"workflow_id": "demo",
|
||
"workflow_version": 1,
|
||
"status": status,
|
||
"progress": 0.5,
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
}
|
||
)
|
||
recovered = db.recover_interrupted_runs("2026-01-02T00:00:00+00:00")
|
||
assert recovered == 1 # 只有 RUNNING 被恢复。
|
||
assert db.get_run("run_orphan")["status"] == "QUEUED"
|
||
assert db.get_run("run_orphan")["updated_at"] == "2026-01-02T00:00:00+00:00"
|
||
assert db.get_run("run_paused")["status"] == "PAUSED"
|
||
assert db.get_run("run_done")["status"] == "COMPLETED"
|
||
# 恢复后调度器可拾起并断点续跑。
|
||
assert db.next_queued_run()["id"] == "run_orphan"
|
||
|
||
def test_restore_run_outputs(tmp_path) -> None:
|
||
"""验证从产物重建节点输出(断点续跑的依据)。"""
|
||
db = Database(tmp_path / "wov.db")
|
||
db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1})
|
||
now = "2026-01-01T00:00:00+00:00"
|
||
db.create_run(
|
||
{
|
||
"id": "run_r",
|
||
"workflow_id": "demo",
|
||
"workflow_version": 1,
|
||
"status": "PAUSED",
|
||
"progress": 0,
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
}
|
||
)
|
||
db.create_artifact(
|
||
{
|
||
"run_id": "run_r",
|
||
"node_id": "extract",
|
||
"name": "frames_manifest",
|
||
"uri": "frames.json",
|
||
"mime_type": "application/json",
|
||
"size": 1,
|
||
}
|
||
)
|
||
assert db.restore_run_outputs("run_r") == {
|
||
"extract": {"frames_manifest": "frames.json"}
|
||
}
|
||
assert db.restore_run_outputs("run_none") == {}
|
||
|
||
|
||
def test_run_source_column_default_and_next_queued(tmp_path) -> None:
|
||
"""source 列默认 upload;主调度器不拾取 batch 来源的运行。"""
|
||
db = Database(tmp_path / "wov.db")
|
||
db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1})
|
||
now = "2026-01-01T00:00:00+00:00"
|
||
db.create_run(
|
||
{
|
||
"id": "run_upload",
|
||
"workflow_id": "demo",
|
||
"workflow_version": 1,
|
||
"status": "QUEUED",
|
||
"progress": 0,
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
}
|
||
)
|
||
db.create_run(
|
||
{
|
||
"id": "run_batch",
|
||
"workflow_id": "demo",
|
||
"workflow_version": 1,
|
||
"status": "QUEUED",
|
||
"progress": 0,
|
||
"source": "batch",
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
}
|
||
)
|
||
# 默认 source 为 upload,可显式指定 batch。
|
||
assert db.get_run("run_upload")["source"] == "upload"
|
||
assert db.get_run("run_batch")["source"] == "batch"
|
||
# 主调度器只取非 batch 运行,批量运行由批量引擎单独拾起。
|
||
assert db.next_queued_run()["id"] == "run_upload"
|
||
|
||
|
||
def test_db_migration_adds_source_column(tmp_path) -> None:
|
||
"""旧库迁移:缺少 source 列的库打开后自动补列并默认 upload。"""
|
||
import sqlite3
|
||
|
||
db_path = tmp_path / "old.db"
|
||
conn = sqlite3.connect(db_path)
|
||
conn.execute(
|
||
"CREATE TABLE workflow_runs (id TEXT PRIMARY KEY, workflow_id TEXT NOT NULL,"
|
||
" workflow_version INTEGER NOT NULL, status TEXT NOT NULL, current_node_id TEXT,"
|
||
" progress REAL NOT NULL DEFAULT 0, error TEXT, input_uri TEXT,"
|
||
" param_overrides TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)"
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
Database(db_path)
|
||
conn = sqlite3.connect(db_path)
|
||
columns = [row[1] for row in conn.execute("PRAGMA table_info(workflow_runs)")]
|
||
conn.close()
|
||
assert "source" in columns
|
||
|
||
|
||
def test_batch_jobs_and_videos_crud(tmp_path) -> None:
|
||
"""批量任务/视频明细的增删改查与排队查询。"""
|
||
db = Database(tmp_path / "wov.db")
|
||
now = "2026-01-01T00:00:00+00:00"
|
||
db.create_batch_job(
|
||
{
|
||
"id": "batch_1", "folder_path": "/videos", "workflow_id": "demo",
|
||
"recursive": 1, "status": "QUEUED", "progress": 0, "total": 2,
|
||
"done": 0, "failed": 0, "current_video": None, "error": None,
|
||
"created_at": now, "updated_at": now,
|
||
}
|
||
)
|
||
assert db.next_queued_batch_job()["id"] == "batch_1"
|
||
assert db.list_batch_job_ids() == ["batch_1"]
|
||
assert db.list_batch_jobs()[0]["total"] == 2
|
||
|
||
db.create_batch_video(
|
||
{
|
||
"id": "bv_1", "job_id": "batch_1", "video_path": "/videos/a.mp4",
|
||
"work_dir": "/videos/a", "run_id": None, "status": "PENDING",
|
||
"error": None, "created_at": now, "updated_at": now,
|
||
}
|
||
)
|
||
db.update_batch_video("bv_1", status="COMPLETED", updated_at=now)
|
||
assert db.get_batch_video("bv_1")["status"] == "COMPLETED"
|
||
assert len(db.list_batch_videos("batch_1")) == 1
|
||
|
||
# 未知字段更新被忽略(不会报错也不会改状态)。
|
||
db.update_batch_video("bv_1", bogus=1, updated_at=now)
|
||
db.update_batch_job("batch_1", bogus=1, updated_at=now)
|
||
assert db.get_batch_video("bv_1")["status"] == "COMPLETED"
|
||
|
||
db.update_batch_job("batch_1", status="COMPLETED", done=1, failed=0, progress=1.0, updated_at=now)
|
||
job = db.get_batch_job("batch_1")
|
||
assert job["status"] == "COMPLETED" and job["done"] == 1
|
||
# 完成后不再排队。
|
||
assert db.next_queued_batch_job() is None
|
||
|
||
db.delete_batch_job("batch_1")
|
||
assert db.get_batch_job("batch_1") is None
|
||
assert db.get_batch_video("bv_1") is None
|