定时目标的底座(总方案 X4,细案 01 做法 15):定时只有一套,目标种类在 scheduler/service 里一处封装

- service.target_of / target_columns:读写目标只经这里。workflow 目标 target_id 同 workflow_id;非工作流目标 workflow_id 存空串(老表的非空约束,只加不改的迁移放宽不了),这条规矩不散到别处
- fire_once 按目标种类分派:工作流那段原样搬进 _fire_workflow(重叠跳过、渲染输入、记触发、发起一字不变);这一波别的种类照实记「未能发起」并写明是哪类目标,不发起运行、不把空串当工作流去跑
- 建任务、PATCH 改工作流经 target_columns 一起写两列;存量任务(target_id 为空)照旧按 workflow_id 发起
- 接口和界面这一波不暴露新种类(第 3 波开放),返回的形状不变
- 新增 tests/test_schedule_target.py
This commit is contained in:
ThearchyHelios
2026-10-16 10:07:15 +08:00
parent 0ce272e94b
commit e3d0fe618c
3 changed files with 217 additions and 32 deletions
+4 -1
View File
@@ -491,6 +491,8 @@ async def create_schedule_row(session: AsyncSession, body: dict[str, Any], *, ac
values = schedule_values(body)
hook = values.pop("webhook")
workflow = await _check_workflow(session, values["workflow_id"], values["run_class"], values["input_template"])
# 目标的几列只经 scheduler/service 的一处封装写(工作流目标:target_id 同 workflow_id)
values |= service.target_columns("workflow", values["workflow_id"])
row = Schedule(**values, workflow_name=workflow.name,
next_run_at=service.next_run(values["cron"], values["timezone"], values["enabled"]),
created_by=actor, **_webhook_columns(hook))
@@ -528,7 +530,8 @@ async def update_schedule(schedule_id: str, request: Request,
if "name" in body:
values["name"] = _name(body["name"])
if "workflow_id" in body:
values["workflow_id"] = _workflow_id(body["workflow_id"])
# 目标的几列一起改(scheduler/service.target_columns):只改 workflow_id 的话 target_id 还指着原来的工作流
values |= service.target_columns("workflow", _workflow_id(body["workflow_id"]))
if "cron" in body:
values["cron"] = _cron(body["cron"])
if "timezone" in body:
+78 -31
View File
@@ -3,6 +3,11 @@
调度循环(loop.py)到点调 fire_once,接口的「立即运行」也调它:重叠跳过、渲染输入、记触发、发起运行这一套
只有一份。发起本身走 engine/launch.py 的 launch_run,和 HTTP 发起是同一套规则——正式运行只能从当前发布版本
发起,这一条不会在定时任务这边被绕过。
**定时的目标**(docs/design/next-round/00-总方案.md X4:定时只有一套):一个定时任务指向一个目标——工作流、评测题集
(第 3 波)或数据源维护(第 3 波)。认领、补跑、重叠跳过、触发记录、通知都只有这一套,fire_once 按目标种类分派。
老表的 workflow_id 是非空约束,只加不改的迁移放宽不了:目标不是工作流时它存空串。这条规矩只在 target_of /
target_columns 这一处,别处读写目标都经它们,不直接碰这两列。
"""
from __future__ import annotations
@@ -64,6 +69,33 @@ INTERRUPTED_REASON = "服务在发起运行前停止,这次触发没有发起
GENERIC_REASON = "发起运行时出错,详细信息见服务日志"
#: 定时的目标种类(Schedule.target_kind),顺序即以后界面上的顺序。这一波 fire_once 只会发起 workflow
TARGET_KINDS = ("workflow", "eval_suite", "source_maintenance")
TARGET_LABEL = {"workflow": "工作流", "eval_suite": "评测题集", "source_maintenance": "数据源维护"}
#: 目标种类这个版本还发起不了(库里有别的版本建的任务、手改过库):照实记「未能发起」,不当成工作流去跑
TARGET_UNSUPPORTED = "这个定时任务的目标是「{kind}」,当前版本还不能按时发起这类目标,这次触发没有发起"
def target_of(schedule: Any) -> tuple[str, str]:
"""一个定时任务的目标:(种类, id)。加列之前建的任务 target_kind 缺省 workflow、target_id 为空,目标就是 workflow_id。
workflow 时 id 以 workflow_id 为准(两列由 target_columns 一起写,对得上;老任务只有这一列)。"""
kind = getattr(schedule, "target_kind", None) or "workflow"
if kind == "workflow":
return kind, schedule.workflow_id or getattr(schedule, "target_id", None) or ""
return kind, getattr(schedule, "target_id", None) or ""
def target_columns(kind: str, target_id: str) -> dict[str, str]:
"""写目标的几列:{target_kind, target_id, workflow_id}。workflow 时 target_id 同 workflow_id;别的种类 workflow_id 存空串
(老表的非空约束)。种类不认识、id 为空抛 ValueError。"""
if kind not in TARGET_KINDS:
raise ValueError(f"不认识的定时目标种类:{kind}")
if not target_id:
raise ValueError("定时任务的目标 id 不能为空")
return {"target_kind": kind, "target_id": target_id, "workflow_id": target_id if kind == "workflow" else ""}
class TemplateError(ValueError):
"""输入模板写错了。str() 是给人看的中文说明,指出是哪个输入。"""
@@ -282,46 +314,61 @@ async def fire_once(schedule_id: str, fire_at: datetime, kind: str, *, actor: st
"""处理一次触发,返回记下的触发记录。同一 (任务, fire_at, kind) 已经有记录(两拍撞在一起、立即运行连点两下)
时不再发起,返回已有的那一条;任务不存在返回 None。
上一次运行还没结束就记 skipped_overlap;输入渲染不了、launch_run 拒绝或出错记 failed_to_start(拒绝写原话,
其他异常写通用说明、详细信息进日志);发起成功记 started 和运行 id。
按目标种类分派(target_of):这一波只有工作流(_fire_workflow),别的种类记 failed_to_start、写明原因,不发起。
"""
async with launch_lock():
async with SessionLocal() as session:
schedule = await session.get(Schedule, schedule_id)
if schedule is None:
return None
name, workflow_id, run_class = schedule.name, schedule.workflow_id, schedule.run_class
target_kind, target_id = target_of(schedule)
name, run_class = schedule.name, schedule.run_class
tz, cron_expr, template = schedule.timezone, schedule.cron, schedule.input_template
try:
inputs = render_inputs(check_template(template), template_values(fire_at, tz, cron_expr))
except (TemplateError, cron.CronError, cron.TimezoneError) as e:
# 存进库之后才坏掉的(手改过库、时区库升级删了时区):照实说是哪一项,不发起
return await _record(schedule_id, fire_at, kind, "failed_to_start", f"无法生成这次运行的输入:{e}", {})
busy = await _active_run(schedule_id)
if busy is not None:
return await _record(schedule_id, fire_at, kind, "skipped_overlap", overlap_reason(*busy), inputs)
fire = await _record(schedule_id, fire_at, kind, "failed_to_start", INTERRUPTED_REASON, inputs, fresh=True)
if fire is None:
return await _existing(schedule_id, fire_at, kind)
from app.engine.launch import LaunchRefused, launch_run
if target_kind != "workflow":
reason = TARGET_UNSUPPORTED.format(kind=TARGET_LABEL.get(target_kind, target_kind))
return await _record(schedule_id, fire_at, kind, "failed_to_start", reason, {})
return await _fire_workflow(schedule_id, fire_at, kind, actor=actor, name=name, workflow_id=target_id,
run_class=run_class, tz=tz, cron_expr=cron_expr, template=template)
_LAUNCHING.add(fire.id)
async def _fire_workflow(schedule_id: str, fire_at: datetime, kind: str, *, actor: str | None, name: str,
workflow_id: str, run_class: str, tz: str, cron_expr: str, template: Any,
) -> ScheduleFire | None:
"""目标是工作流的一次触发(持着 launch_lock)。
上一次运行还没结束就记 skipped_overlap;输入渲染不了、launch_run 拒绝或出错记 failed_to_start(拒绝写原话,
其他异常写通用说明、详细信息进日志);发起成功记 started 和运行 id。
"""
try:
inputs = render_inputs(check_template(template), template_values(fire_at, tz, cron_expr))
except (TemplateError, cron.CronError, cron.TimezoneError) as e:
# 存进库之后才坏掉的(手改过库、时区库升级删了时区):照实说是哪一项,不发起
return await _record(schedule_id, fire_at, kind, "failed_to_start", f"无法生成这次运行的输入:{e}", {})
busy = await _active_run(schedule_id)
if busy is not None:
return await _record(schedule_id, fire_at, kind, "skipped_overlap", overlap_reason(*busy), inputs)
fire = await _record(schedule_id, fire_at, kind, "failed_to_start", INTERRUPTED_REASON, inputs, fresh=True)
if fire is None:
return await _existing(schedule_id, fire_at, kind)
from app.engine.launch import LaunchRefused, launch_run
_LAUNCHING.add(fire.id)
try:
try:
try:
async with SessionLocal() as session:
run = await launch_run(session, workflow_id=workflow_id, run_class=run_class,
input_payload=inputs, actor=actor or actor_label(name),
schedule_id=schedule_id, origin_kind="schedule",
origin={"schedule_id": schedule_id, "schedule": name,
"fire_id": fire.id, "fire_kind": kind})
except LaunchRefused as e:
return await _finish(fire.id, status="failed_to_start", reason=e.message)
except Exception: # noqa: BLE001 - 发起失败不能让调度循环停下;详细信息进日志,记录上写通用说明
logger.exception("定时任务「%s」(%s)发起运行失败", name, schedule_id)
return await _finish(fire.id, status="failed_to_start", reason=GENERIC_REASON)
return await _finish(fire.id, status="started", reason=None, run_id=run.id)
finally:
_LAUNCHING.discard(fire.id)
async with SessionLocal() as session:
run = await launch_run(session, workflow_id=workflow_id, run_class=run_class,
input_payload=inputs, actor=actor or actor_label(name),
schedule_id=schedule_id, origin_kind="schedule",
origin={"schedule_id": schedule_id, "schedule": name,
"fire_id": fire.id, "fire_kind": kind})
except LaunchRefused as e:
return await _finish(fire.id, status="failed_to_start", reason=e.message)
except Exception: # noqa: BLE001 - 发起失败不能让调度循环停下;详细信息进日志,记录上写通用说明
logger.exception("定时任务「%s」(%s)发起运行失败", name, schedule_id)
return await _finish(fire.id, status="failed_to_start", reason=GENERIC_REASON)
return await _finish(fire.id, status="started", reason=None, run_id=run.id)
finally:
_LAUNCHING.discard(fire.id)
async def record_skip(schedule_id: str, fire_at: datetime, reason: str) -> ScheduleFire | None:
+135
View File
@@ -0,0 +1,135 @@
"""定时任务的目标(docs/design/next-round/00-总方案.md X4:定时只有一套)。
骨架给 Schedule 加了 target_kind / target_id;workflow_id 是老表上的非空约束,只加不改的迁移放宽不了。规矩只在
scheduler/service.py 的一处封装里:workflow 目标的 target_id 同 workflow_id;不是工作流的目标 workflow_id 存空串。
这里守:
- 存量任务(target_kind 是缺省的 workflow、target_id 为空)照旧按 workflow_id 发起,行为一字不变;
- 建任务、改工作流都经同一处封装写目标,两列对得上;
- fire_once 按目标种类分派。这一波只实现 workflow,别的种类照实记「未能发起」并写明原因,不发起运行、不悄悄当成
工作流去跑空串;
- 接口这一波不暴露新种类(接口返回的形状不变,测试见 test_schedules_api.py 的 SCHEDULE_KEYS)。
工作流、任务名都是虚构的。
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete, func, select
from app.db.base import SessionLocal
from app.db.models import Run, Schedule, ScheduleFire, Workflow, WorkflowVersion
from app.engine.runner import run_manager
from app.main import app
from app.scheduler import service
UTC = timezone.utc
NINE = datetime(2026, 10, 5, 1, 0, tzinfo=UTC)
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
async with SessionLocal() as session:
await session.execute(delete(Schedule))
await session.commit()
yield
await run_manager.shutdown()
def _node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def _graph() -> dict:
return {"nodes": [_node("start", "input", fields=[{"name": "stat_date", "required": True}]),
_node("out", "output", fields=[{"name": "r", "value": "完成 {{ input.stat_date }}"}])],
"edges": [{"source": "start", "target": "out"}]}
async def _workflow() -> str:
async with SessionLocal() as session:
wf = Workflow(name=f"门店日报-{uuid.uuid4().hex[:6]}", graph=_graph(), status="published", published_version=1)
session.add(wf)
await session.flush()
session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=_graph()))
await session.commit()
return wf.id
def test_target_of_a_legacy_row_reads_the_workflow():
"""加列之前建的任务:target_kind 缺省 workflow、target_id 为空,目标就是 workflow_id。"""
row = Schedule(name="老任务", workflow_id="wf-legacy", cron="0 9 * * *")
assert service.target_of(row) == ("workflow", "wf-legacy")
row.target_kind = "workflow"
row.target_id = None
assert service.target_of(row) == ("workflow", "wf-legacy")
def test_target_columns_keep_the_two_columns_in_step():
assert service.target_columns("workflow", "wf-1") == {"target_kind": "workflow", "target_id": "wf-1",
"workflow_id": "wf-1"}
# 非工作流目标:workflow_id 存空串(老表的非空约束),目标 id 只在 target_id
assert service.target_columns("eval_suite", "suite-1") == {"target_kind": "eval_suite", "target_id": "suite-1",
"workflow_id": ""}
row = Schedule(name="评测", cron="0 9 * * *", **service.target_columns("eval_suite", "suite-1"))
assert service.target_of(row) == ("eval_suite", "suite-1")
with pytest.raises(ValueError):
service.target_columns("nonsense", "x")
with pytest.raises(ValueError):
service.target_columns("workflow", "")
async def test_creating_and_retargeting_through_the_api_write_both_columns():
wf_a, wf_b = await _workflow(), await _workflow()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
made = await client.post("/api/schedules", json={
"name": "每日早报", "workflow_id": wf_a, "cron": "0 9 * * *", "timezone": "Asia/Shanghai",
"input_template": {"stat_date": "{{ schedule.yesterday }}"}})
assert made.status_code == 201, made.text
body = made.json()
assert "target_kind" not in body and "target_id" not in body, "这一波接口不暴露新种类"
async with SessionLocal() as session:
row = await session.get(Schedule, body["id"])
assert (row.target_kind, row.target_id, row.workflow_id) == ("workflow", wf_a, wf_a)
moved = await client.patch(f"/api/schedules/{body['id']}", json={"workflow_id": wf_b, "version": body["version"]})
assert moved.status_code == 200, moved.text
async with SessionLocal() as session:
row = await session.get(Schedule, body["id"])
assert (row.target_kind, row.target_id, row.workflow_id) == ("workflow", wf_b, wf_b)
async def test_a_legacy_schedule_still_fires_its_workflow():
wf = await _workflow()
async with SessionLocal() as session:
row = Schedule(name="老任务", workflow_id=wf, cron="0 9 * * *", timezone="Asia/Shanghai", run_class="formal",
input_template={"stat_date": "{{ schedule.yesterday }}"}, next_run_at=NINE)
session.add(row)
await session.commit()
schedule_id = row.id
fire = await service.fire_once(schedule_id, NINE, "manual")
assert fire is not None and fire.status == "started" and fire.run_id
async with SessionLocal() as session:
run = await session.get(Run, fire.run_id)
assert run.workflow_id == wf and run.input == {"stat_date": "2026-10-04"}
async def test_a_target_kind_this_wave_cannot_fire_is_recorded_not_run():
async with SessionLocal() as session:
row = Schedule(name="题集回归", cron="0 9 * * *", timezone="Asia/Shanghai",
**service.target_columns("eval_suite", "suite-x"), next_run_at=NINE)
session.add(row)
await session.commit()
schedule_id = row.id
runs_before = await session.scalar(select(func.count()).select_from(Run))
fire = await service.fire_once(schedule_id, NINE, "manual")
assert fire is not None and fire.status == "failed_to_start" and fire.run_id is None
assert fire.reason == service.TARGET_UNSUPPORTED.format(kind=service.TARGET_LABEL["eval_suite"])
async with SessionLocal() as session:
assert await session.scalar(select(func.count()).select_from(Run)) == runs_before, "不能发起任何运行"
assert await session.scalar(select(func.count()).select_from(ScheduleFire)
.where(ScheduleFire.schedule_id == schedule_id)) == 1