feat: 文件夹批量处理引擎(后端)

- BatchWorker 单线程轮询 batch_jobs 表,处理 source=batch 的运行,
  与主调度器互不抢占(next_queued_run 排除 batch 来源)
- 直接读取用户所选文件夹下的视频逐个执行流水线,不上传到工作目录;
  中间态与产物落在视频旁同名文件夹,batch.done.json 完成标记去重
- 支持暂停/继续、失败容错(单视频失败不阻塞后续)、删除任务只清库
- 孤儿清理跳过 source=batch 运行,防止误删用户视频文件夹
- workflow_runs 新增 source 列(upload/batch),旧库自动迁移
This commit is contained in:
2026-08-23 16:25:09 +08:00
parent b16e0e9f3c
commit 711867e79f
10 changed files with 2056 additions and 9 deletions
+199 -6
View File
@@ -73,6 +73,7 @@ class Database:
error TEXT,
input_uri TEXT,
param_overrides TEXT,
source TEXT NOT NULL DEFAULT 'upload',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(workflow_id) REFERENCES workflows(id)
@@ -91,6 +92,39 @@ class Database:
UNIQUE(run_id, name),
FOREIGN KEY(run_id) REFERENCES workflow_runs(id)
);
-- 批量处理任务表:一次"文件夹批量处理"对应一条记录,记录目标
-- 文件夹、所选工作流与整体状态。批量引擎与 Web 页面共用。
CREATE TABLE IF NOT EXISTS batch_jobs (
id TEXT PRIMARY KEY,
folder_path TEXT NOT NULL,
workflow_id TEXT NOT NULL,
recursive INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL,
progress REAL NOT NULL DEFAULT 0,
total INTEGER NOT NULL DEFAULT 0,
done INTEGER NOT NULL DEFAULT 0,
failed INTEGER NOT NULL DEFAULT 0,
current_video TEXT,
error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 批量视频明细表:一次批量任务处理的每个视频一条记录,保存其
-- 对应的工作流 run(断点续跑复用 workflow_runs 的产物状态)。
CREATE TABLE IF NOT EXISTS batch_videos (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
video_path TEXT NOT NULL,
work_dir TEXT NOT NULL,
run_id TEXT,
status TEXT NOT NULL,
error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(job_id) REFERENCES batch_jobs(id)
);
"""
)
@@ -101,7 +135,10 @@ class Database:
]
if "param_overrides" not in columns:
conn.execute("ALTER TABLE workflow_runs ADD COLUMN param_overrides TEXT")
# 旧库迁移:workflow_runs 补充 source 列(upload=网页上传 / batch=批量处理),
# 批量引擎与主调度器据此隔离任务,避免互相抢占。
if "source" not in columns:
conn.execute("ALTER TABLE workflow_runs ADD COLUMN source TEXT NOT NULL DEFAULT 'upload'")
def upsert_workflow(self, workflow: dict[str, Any]) -> None:
"""插入或更新工作流概要信息。"""
with self._connect() as conn:
@@ -210,15 +247,20 @@ class Database:
return versions
def create_run(self, run: dict[str, Any]) -> None:
"""创建一条排队中的工作流运行记录。"""
"""创建一条排队中的工作流运行记录。
source 标识任务来源:upload(网页上传,默认)由主调度器执行;
batch(文件夹批量处理)由批量引擎执行,input_uri 直接指向本地视频。
"""
with self._connect() as conn:
conn.execute(
"""
INSERT INTO workflow_runs (
id, workflow_id, workflow_version, status, current_node_id,
progress, error, input_uri, param_overrides, created_at, updated_at
progress, error, input_uri, param_overrides, source,
created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
run["id"],
@@ -232,11 +274,11 @@ class Database:
json.dumps(run["param_overrides"], ensure_ascii=False)
if run.get("param_overrides")
else None,
run.get("source", "upload"),
run["created_at"],
run["updated_at"],
),
)
def get_run(self, run_id: str) -> dict[str, Any] | None:
"""按 ID 读取任务运行记录。"""
with self._connect() as conn:
@@ -308,12 +350,14 @@ class Database:
只取 QUEUEDPAUSED 任务必须由用户显式 resume(转回 QUEUED)后调度器
才重新执行。修复回归——此前把 PAUSED 也当可执行任务拾起,execute_run
会先置 RUNNING 再检查暂停,导致"点击暂停反而开始任务"
同时排除 source=batch 的批量运行:批量任务由批量引擎使用视频旁的
同名文件夹作为 storage 执行,主调度器拾起会用错存储目录。
"""
with self._connect() as conn:
row = conn.execute(
"""
SELECT * FROM workflow_runs
WHERE status = 'QUEUED'
WHERE status = 'QUEUED' AND source != 'batch'
ORDER BY created_at ASC
LIMIT 1
"""
@@ -416,3 +460,152 @@ class Database:
with self._connect() as conn:
conn.execute("DELETE FROM artifacts WHERE run_id = ?", (run_id,))
conn.execute("DELETE FROM workflow_runs WHERE id = ?", (run_id,))
# ------------------------------------------------------------------
# 批量处理任务(batch_jobs / batch_videos)数据访问。
# 批量引擎与批量管理页共用这些方法,规则与 workflow_runs 一致。
# ------------------------------------------------------------------
def create_batch_job(self, job: dict[str, Any]) -> None:
"""插入一条批量处理任务记录。"""
with self._connect() as conn:
conn.execute(
"""
INSERT INTO batch_jobs (
id, folder_path, workflow_id, recursive, status, progress,
total, done, failed, current_video, error, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
job["id"],
job["folder_path"],
job["workflow_id"],
int(job.get("recursive", 1)),
job["status"],
float(job.get("progress", 0)),
int(job.get("total", 0)),
int(job.get("done", 0)),
int(job.get("failed", 0)),
job.get("current_video"),
job.get("error"),
job["created_at"],
job["updated_at"],
),
)
def get_batch_job(self, job_id: str) -> dict[str, Any] | None:
"""按 ID 读取批量任务记录。"""
with self._connect() as conn:
row = conn.execute("SELECT * FROM batch_jobs WHERE id = ?", (job_id,)).fetchone()
return dict(row) if row else None
def list_batch_jobs(self, limit: int = 50) -> list[dict[str, Any]]:
"""按创建时间倒序返回最近的批量任务。"""
with self._connect() as conn:
rows = conn.execute(
"SELECT * FROM batch_jobs ORDER BY created_at DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(row) for row in rows]
def list_batch_job_ids(self) -> list[str]:
"""返回全部批量任务 ID,供孤儿清理区分批量运行使用。"""
with self._connect() as conn:
rows = conn.execute("SELECT id FROM batch_jobs").fetchall()
return [row["id"] for row in rows]
def next_queued_batch_job(self) -> dict[str, Any] | None:
"""按创建时间返回最早一条排队(QUEUED)的批量任务。
批量引擎单线程顺序处理,同一时刻只执行一个批量任务。
"""
with self._connect() as conn:
row = conn.execute(
"""
SELECT * FROM batch_jobs
WHERE status = 'QUEUED'
ORDER BY created_at ASC
LIMIT 1
"""
).fetchone()
return dict(row) if row else None
def update_batch_job(self, job_id: str, **fields: Any) -> None:
"""更新批量任务字段,同时刷新 updated_at;未知字段会被忽略。"""
allowed = {
"status",
"progress",
"total",
"done",
"failed",
"current_video",
"error",
}
updates = {key: value for key, value in fields.items() if key in allowed}
if not updates:
return
updates["updated_at"] = fields.get("updated_at")
assignments = ", ".join(f"{key} = ?" for key in updates)
values = list(updates.values()) + [job_id]
with self._connect() as conn:
conn.execute(f"UPDATE batch_jobs SET {assignments} WHERE id = ?", values)
def delete_batch_job(self, job_id: str) -> None:
"""删除批量任务记录及其全部视频明细(不含 workflow_runs)。"""
with self._connect() as conn:
conn.execute("DELETE FROM batch_videos WHERE job_id = ?", (job_id,))
conn.execute("DELETE FROM batch_jobs WHERE id = ?", (job_id,))
def create_batch_video(self, item: dict[str, Any]) -> None:
"""插入一条批量视频明细记录。"""
with self._connect() as conn:
conn.execute(
"""
INSERT INTO batch_videos (
id, job_id, video_path, work_dir, run_id, status, error,
created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
item["id"],
item["job_id"],
item["video_path"],
item["work_dir"],
item.get("run_id"),
item["status"],
item.get("error"),
item["created_at"],
item["updated_at"],
),
)
def get_batch_video(self, video_id: str) -> dict[str, Any] | None:
"""按 ID 读取批量视频明细。"""
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM batch_videos WHERE id = ?", (video_id,)
).fetchone()
return dict(row) if row else None
def list_batch_videos(self, job_id: str) -> list[dict[str, Any]]:
"""按创建时间返回一次批量任务的全部视频明细。"""
with self._connect() as conn:
rows = conn.execute(
"SELECT * FROM batch_videos WHERE job_id = ? ORDER BY created_at",
(job_id,),
).fetchall()
return [dict(row) for row in rows]
def update_batch_video(self, video_id: str, **fields: Any) -> None:
"""更新批量视频字段,同时刷新 updated_at;未知字段会被忽略。"""
allowed = {"run_id", "status", "error"}
updates = {key: value for key, value in fields.items() if key in allowed}
if not updates:
return
updates["updated_at"] = fields.get("updated_at")
assignments = ", ".join(f"{key} = ?" for key in updates)
values = list(updates.values()) + [video_id]
with self._connect() as conn:
conn.execute(f"UPDATE batch_videos SET {assignments} WHERE id = ?", values)