feat: 批量分块流水线、本地模型显存让渡与任务列表分工
批量引擎改为「分块流水线」:视频按 WOV_BATCH_STAGE_GROUP_SIZE(默认 8)分组, 组内按 DAG 拓扑序跑完全部视频(全部 extract → 全部 ASR → 全部翻译 → 全部 ASS) 再进入下一组,本地模型每组只加载一次、卸载一次,而不是每个视频来回加载卸载; 产物仍按组增量落到视频旁。调度器新增 execute_run(run_id, stop_after=节点): 该节点完成后任务保持 RUNNING 不收尾,下一次调用从产物表跳过已完成节点继续, 用于实现阶段边界。 - nodes/llm.py:翻译节点结束释放本机 Ollama 显存(node 参数 unload_after > LLM_UNLOAD_AFTER > 本机 loopback 端点默认卸载,云端端点不卸载;卸载失败只告警), 新增 keep_model.flag 语义(阶段内保持常驻)与 release_local_model(); 新增节点内暂停(按批 20 行检查 paused.flag,抛 PauseRequested,调度器保持 PAUSED)。 - src/wov_app/batch.py:分组阶段执行与阶段末统一释放显存;失败视频只在它失败 节点的那个阶段重试(避免 LLM 已常驻时重跑 ASR 抢显存);任务没有明细时保持 QUEUED 等登记完成、仍有未完成视频时置回 QUEUED 自愈(原先留 RUNNING 会卡死: 引擎只拾取 QUEUED,任务停在“运行中但没人推进”);无失败视频时删除任务级空目录; 每个阶段开始前清理 paused.flag / keep_model.flag,避免强杀残留影响后续阶段。 - src/wov_app/config.py:新增 WOV_BATCH_STAGE_GROUP_SIZE(设为 1 即旧的每视频全链路)。 - 任务列表与批量页分工:GET /api/runs 默认排除 source=batch(一个批量任务会产生 N 条单视频 run,会把 20 条窗口占满;且任务管理页的暂停/重试/删除对批量 run 语义不成立),需要排查时用 include_batch=1;作为补偿批量页详情新增阶段列 (阶段 i/N · 中文标签,由该视频 run 的 current_node_id 在 DAG 拓扑序中的位置 推导,节点类型映射中文标签)。阶段只有节点边界粒度,句级进度不落库、只在日志。 - 顺带纳入此前未提交的批量僵尸状态恢复:recover_interrupted_batch_jobs 除 RUNNING 外也把「COMPLETED 但仍含未结束视频」的任务置回 QUEUED;fix_zombie_batch_jobs.py 改为按条件扫描并支持 --apply 预览;批量页明细只列本批真正处理过的视频。 测试新增/更新:分块流水线调用顺序(组内按节点跑完再下一组)、每组只释放一次模型、 阶段内保持常驻标志、翻译按批暂停、失败视频不跨阶段推进、任务无明细/中途登记视频时 置回 QUEUED、任务工作空间与残留信号清理、任务列表默认过滤批量 run、详情阶段字段、 前端阶段列渲染;全量 507 passed(唯一失败为既有素材缺失的 integration 用例)。
This commit is contained in:
@@ -124,11 +124,19 @@ class WorkflowScheduler:
|
||||
return None
|
||||
return outputs_by_node.get(node_id, {}).get(key)
|
||||
|
||||
def execute_run(self, run_id: str) -> None:
|
||||
"""执行单个任务:加载 DAG、按拓扑顺序调用节点并登记产物。"""
|
||||
def execute_run(self, run_id: str, stop_after: str | None = None) -> None:
|
||||
"""执行单个任务:加载 DAG、按拓扑顺序调用节点并登记产物。
|
||||
|
||||
stop_after 指定"只执行到该节点"(批量分块流水线的阶段执行):该节点完成
|
||||
后任务保持 RUNNING 不收尾,下一次调用从产物表跳过已完成节点继续后面的
|
||||
阶段;不传时执行整条 DAG 并收尾(登记 final_outputs、标 COMPLETED)。
|
||||
"""
|
||||
run = self.db.get_run(run_id)
|
||||
# 任务不存在或不在可执行状态(排队/暂停)时直接返回,避免重复执行。
|
||||
if run is None or run["status"] not in ("QUEUED", "PAUSED"):
|
||||
# 任务不存在或不在可执行状态时直接返回,避免重复执行。RUNNING 只来自
|
||||
# 分阶段执行的上一个阶段(任务保持 RUNNING 等下一阶段)或进程异常中断的
|
||||
# 残留,续跑时已完成节点由产物表跳过;调度器只拾取 QUEUED 任务、批量
|
||||
# 引擎单线程推进,不会出现两个驱动方重复执行同一任务。
|
||||
if run is None or run["status"] not in ("QUEUED", "PAUSED", "RUNNING"):
|
||||
return
|
||||
# 已暂停的任务不自动续跑:直接返回保持 PAUSED,等用户显式 resume
|
||||
# (resume 转回 QUEUED 后才执行);否则暂停会被立刻覆盖成 RUNNING。
|
||||
@@ -153,6 +161,9 @@ class WorkflowScheduler:
|
||||
definition = WorkflowDefinition.from_dict(version["definition"])
|
||||
definition.validate()
|
||||
ordered = topological_sort(definition)
|
||||
# 阶段节点必须存在于 DAG:写错会让任务永远停在 RUNNING 无人推进。
|
||||
if stop_after is not None and stop_after not in ordered:
|
||||
raise ValueError(f"stop_after node not in workflow: {stop_after}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("任务 %s 工作流定义无效,标记失败: %s", run_id, exc)
|
||||
self.db.update_run(
|
||||
@@ -177,6 +188,9 @@ class WorkflowScheduler:
|
||||
return
|
||||
# 断点续跑:跳过已产出结果的节点(其产物已作为输入可用)。
|
||||
if node_id in outputs_by_node:
|
||||
# 阶段边界落在已完成的节点上:直接结束本阶段。
|
||||
if node_id == stop_after:
|
||||
break
|
||||
continue
|
||||
# 当前节点进度 = 已完成节点数 / 总节点数。
|
||||
node_spec = next(item for item in definition.nodes if item.id == node_id)
|
||||
@@ -244,6 +258,17 @@ class WorkflowScheduler:
|
||||
time.monotonic() - node_started,
|
||||
time.monotonic() - run_started,
|
||||
)
|
||||
# 阶段边界:本阶段节点已完成,不再执行后续节点。
|
||||
if node_id == stop_after:
|
||||
break
|
||||
# 分阶段执行:本阶段节点已全部完成(含本轮跳过的情况),任务保持
|
||||
# RUNNING 等下一个阶段,不做 final_outputs 与完成标记。
|
||||
if stop_after is not None:
|
||||
logger.info(
|
||||
"任务 %s 阶段完成: 已执行到节点 %s(分阶段执行,保持 RUNNING 等下一阶段)",
|
||||
run_id, stop_after,
|
||||
)
|
||||
return
|
||||
# 处理 final_outputs,为用户端提供简洁的下载别名。
|
||||
for alias, ref in definition.final_outputs.items():
|
||||
resolved = self._resolve_ref(ref, run.get("input_uri"), outputs_by_node)
|
||||
|
||||
Reference in New Issue
Block a user