mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:31:58 +00:00
定时目标的底座(总方案 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:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user