feat: 任务断点续跑/暂停中断/LLM 过滤优化与调度容错
调度与状态机: - 修复 PAUSED 任务被拾起后复活执行(点击暂停反而开始任务):next_queued_run 只取 QUEUED,execute_run 以 PAUSED 进入直接返回,暂停必须显式 resume - 重启恢复:启动时 recover_interrupted_runs 把遗留 RUNNING 置 QUEUED(保留产物) - 暂停信号 paused.flag:暂停接口写、继续/重试清除,OCR 逐帧检查秒级中断, 节点内被暂停保持 PAUSED 不误报 FAILED - 调度轮询容错:_loop 异常不杀死线程(曾致任务永久停留 QUEUED) subtitle-ocr 节点级断点: - ocr_partial.jsonl 逐帧存档,重启/暂停后只处理未处理帧,产物与一次跑完一致 - 进度日志携带窗口平均耗时与线程数;取消后抑制进度日志井喷 llm-filter 过滤质量与限流自适应: - 上下文净化:喂给 LLM 的是过滤后的字幕(规则层垃圾从上下文剔除) - 正则确定性过滤:裸网址域名、HTML/水印模式直接删除 - 429/5xx 指数退避重试;worker 限流错误 report_failure 内存临时降最大线程数 并缩容(无错误窗口回升),失败条目降并发后重试一轮 - 保留长文本保护(noise 不删 ≥min_keep_len 文本,LLM 判定不稳的必要兜底) 前端: - 工作流编排页支持选择工作流编辑(加载最新/历史版本)、版本历史面板、 新建/编辑双模式;管理后台编辑跳转 workflow.html?edit=<id> 工作流:ocr-subtitle v7(filter pool_max_workers=20、pool_fast_threshold=1)
This commit is contained in:
@@ -612,6 +612,132 @@ def test_execute_paused_run_not_run(tmp_path, monkeypatch) -> None:
|
||||
assert called == []
|
||||
|
||||
|
||||
def test_execute_paused_run_stays_paused(tmp_path, monkeypatch) -> None:
|
||||
"""PAUSED 任务不被 execute_run 复活:不置 RUNNING、不执行任何节点。
|
||||
|
||||
修复回归:PAUSED 任务被调度器拾起后曾先置 RUNNING 再检查,节点循环
|
||||
读到的是刚改的 RUNNING 状态,"暂停检查"永远不成立 → 任务被复活继续跑
|
||||
(用户观察到的"点击暂停反而开始任务")。修复后 PAUSED 直接返回保持暂停。
|
||||
"""
|
||||
db = _db(tmp_path)
|
||||
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
|
||||
db.create_workflow_version("flow", 1, _two_node_definition().to_dict())
|
||||
now = "2026-01-01T00:00:00+00:00"
|
||||
db.create_run(
|
||||
{
|
||||
"id": "run_paused",
|
||||
"workflow_id": "flow",
|
||||
"workflow_version": 1,
|
||||
"status": "PAUSED",
|
||||
"progress": 0.5,
|
||||
"current_node_id": "a",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
called = []
|
||||
|
||||
def fake_invoke(node_type, request):
|
||||
called.append(node_type)
|
||||
return InvokeResponse(status="completed", outputs={})
|
||||
|
||||
monkeypatch.setattr(registry, "invoke", fake_invoke)
|
||||
scheduler = WorkflowScheduler(db, tmp_path / "storage")
|
||||
scheduler.execute_run("run_paused")
|
||||
# 状态保持 PAUSED(未被置为 RUNNING),节点一个都不执行。
|
||||
assert db.get_run("run_paused")["status"] == "PAUSED"
|
||||
assert called == []
|
||||
|
||||
def test_execute_paused_during_node_keeps_paused(tmp_path, monkeypatch) -> None:
|
||||
"""节点内被暂停(节点检测到暂停信号后中止):保持 PAUSED 不标 FAILED。
|
||||
|
||||
节点内暂停响应:subtitle-ocr 检查到 paused.flag 后中止并返回失败;
|
||||
调度器捕获节点异常时应检查任务状态——若已被置为 PAUSED(用户点了暂停),
|
||||
则保持 PAUSED 等待 resume 从断点续跑,而不是覆盖为 FAILED。
|
||||
"""
|
||||
db = _db(tmp_path)
|
||||
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
|
||||
db.create_workflow_version("flow", 1, _two_node_definition().to_dict())
|
||||
now = "2026-01-01T00:00:00+00:00"
|
||||
db.create_run(
|
||||
{
|
||||
"id": "run_paused",
|
||||
"workflow_id": "flow",
|
||||
"workflow_version": 1,
|
||||
"status": "QUEUED",
|
||||
"progress": 0,
|
||||
"input_uri": str(tmp_path / "in.txt"),
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
|
||||
def fake_invoke(node_type, request):
|
||||
# 模拟节点内暂停:任务已被置 PAUSED,节点随后中止并抛异常。
|
||||
db.pause_run("run_paused", now)
|
||||
raise RuntimeError("OCR interrupted by pause")
|
||||
|
||||
monkeypatch.setattr(registry, "invoke", fake_invoke)
|
||||
WorkflowScheduler(db, tmp_path / "storage").execute_run("run_paused")
|
||||
# 保持 PAUSED,不标 FAILED、不写 error(等待用户 resume 断点续跑)。
|
||||
run = db.get_run("run_paused")
|
||||
assert run["status"] == "PAUSED"
|
||||
assert run["error"] is None
|
||||
|
||||
|
||||
def test_scheduler_loop_survives_poll_exception(tmp_path, monkeypatch) -> None:
|
||||
"""调度轮询遇异常不退出线程:下一轮继续执行排队任务。
|
||||
|
||||
修复回归:_loop 中 next_queued_run/execute_run 的未捕获异常曾杀死调度
|
||||
线程(worker 进程只剩 uvicorn 主线程),任务永远停留在 QUEUED——
|
||||
run_011d01f19999 实际发生:回退 QUEUED 后调度器不再拾起,新配置
|
||||
(pool_max_workers=20)因此从未执行。
|
||||
"""
|
||||
db = _db(tmp_path)
|
||||
input_file = tmp_path / "input.txt"
|
||||
input_file.write_text("resilient", encoding="utf-8")
|
||||
_register_echo()
|
||||
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
|
||||
db.create_workflow_version("flow", 1, _echo_definition().to_dict())
|
||||
now = "2026-01-01T00:00:00+00:00"
|
||||
db.create_run(
|
||||
{
|
||||
"id": "run_resilient",
|
||||
"workflow_id": "flow",
|
||||
"workflow_version": 1,
|
||||
"status": "QUEUED",
|
||||
"progress": 0,
|
||||
"input_uri": str(input_file),
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
|
||||
# 第一次轮询抛异常(模拟数据库抖动等),后续正常。
|
||||
calls = {"n": 0}
|
||||
real_next = db.next_queued_run
|
||||
|
||||
def flaky_next():
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise RuntimeError("transient db error")
|
||||
return real_next()
|
||||
|
||||
monkeypatch.setattr(db, "next_queued_run", flaky_next)
|
||||
scheduler = WorkflowScheduler(db, tmp_path / "storage", interval_seconds=0.05)
|
||||
scheduler.start()
|
||||
try:
|
||||
deadline = time.monotonic() + 10
|
||||
while time.monotonic() < deadline:
|
||||
if db.get_run("run_resilient")["status"] in {"COMPLETED", "FAILED"}:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
finally:
|
||||
scheduler.stop()
|
||||
# 第一次异常后调度线程仍存活,第二轮回合把任务执行完成。
|
||||
assert db.get_run("run_resilient")["status"] == "COMPLETED"
|
||||
assert calls["n"] >= 2
|
||||
|
||||
def test_execute_pause_after_last_node_keeps_paused(tmp_path, monkeypatch) -> None:
|
||||
"""验证全部节点完成但运行中被暂停时保持 PAUSED;续跑补做收尾后完成。"""
|
||||
db = _db(tmp_path)
|
||||
|
||||
Reference in New Issue
Block a user