Files
AgentLab/backend/tests/test_scheduler_loop.py
T
ThearchyHelios a2a19df9df 定时任务后台:三张表、输入模板、调度循环和 Webhook 推送,设置加「通知」一组
- 表:schedules(Webhook 地址和签名密钥加密存放、next_run_at 存 UTC、version 乐观锁)、schedule_fires((任务,
  fire_at, kind) 唯一)、webhook_deliveries;新表由 create_all 建,不用迁移
- 输入模板:{{ schedule.yesterday }} 这类变量按计划触发时间在任务时区算(不按「现在」:补发晚了、零点前后立即
  运行,「昨天」都是那次触发的昨天),上一次计划触发时间按 cron 往前找,同一次触发永远渲染出同一份输入;未知
  变量、未知过滤器、{{ }} 不成对都拦下,声明了入口字段的工作流还对照字段名和必填项
- 调度循环:每 15 秒一拍,条件 UPDATE 认领到点的任务并推进到下一次,两拍并发只有一拍认领得到,触发记录的唯一
  约束是第二道保险;上一次运行没结束(含停在审批、服务重启挂起的)记跳过;错过的只看最近一次,在宽限内补一次、
  超出记跳过,积压不补;发起走 launch_run,拒绝的原话记成未能发起,别的异常记通用说明、详情进日志;stop 等手上
  这一拍收尾,开关关着时 start 不起循环
- Webhook:通用 JSON(可选 HMAC 签名头)、飞书文本、企业微信 markdown、钉钉 markdown,按各平台算法签名;运行
  结束、失败、确有待审批时按订阅推一次,结束的等封存好再推,没订阅、没配 Webhook 的也记下已处理,之后配上不会
  补推旧的;10 秒超时、失败重试 3 次(4xx 不重试),每次推送记一条记录;通知里没有报告数字和原始报错,日志和
  错误里只有主机名(httpx 自己的请求日志也换成主机名)
- 设置 notify.public_base_url:通知里的运行链接 = 它 + /runs/{id},只收 http(s) 地址,末尾的 / 去掉
2026-10-05 08:37:57 +08:00

342 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""定时任务的调度循环(app/scheduler/loop.py、service.fire_once)。
- 到点只发起一次:条件 UPDATE 认领(WHERE next_run_at = 旧值)并推进到下一次,两拍并发也只有一拍认领得到;
触发记录 (任务, fire_at, kind) 唯一,撞上约束的不再发起
- 发起走 engine/launch.py 的 launch_run:记在「定时任务:<名字>」名下、带 schedule_id,输入按 fire_at 渲染;
正式运行用当前发布版本,没有发布版本被拒,拒绝的原话记成 failed_to_start;别的异常记通用说明、详情进日志
- 上一次的运行还没结束(排队、运行中、停在审批 / 挂起都算)记 skipped_overlap
- 错过的触发:最近一次在宽限内补一次(kind=misfire),更早的积压只记一条跳过;最近一次也超出宽限就记一条
skipped_misfire,推进到未来的下一次,不补积压
- start / stop:起一个 asyncio 任务按拍执行,stop 干净地停下;AGENTLAB_SCHEDULER 关着时 start 不起循环
工作流、字段、任务名都是虚构的。
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import delete, func, select
from app import scheduler
from app.core.config import Settings
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.scheduler import loop, service
UTC = timezone.utc
#: 上海 2026-10-05(周一)09:00
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(text: str) -> dict:
return {
"nodes": [node("start", "input", fields=[{"name": "stat_date", "required": True}]),
node("out", "output", fields=[{"name": "r", "value": f"{text} {{{{ input.stat_date }}}}"}])],
"edges": [{"source": "start", "target": "out"}],
}
async def workflow(*, published: bool = True, canvas: str = "画布", snapshot: str = "发布版") -> str:
async with SessionLocal() as session:
wf = Workflow(name=f"门店周报-{uuid.uuid4().hex[:6]}", graph=graph(canvas),
status="published" if published else "draft", published_version=1 if published else None)
session.add(wf)
await session.flush()
session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=graph(snapshot)))
await session.commit()
return wf.id
async def schedule(workflow_id: str, *, cron: str = "0 9 * * *", next_run_at: datetime | None = NINE,
run_class: str = "formal", grace: int = 3600, enabled: bool = True, name: str = "周一早报") -> str:
async with SessionLocal() as session:
row = Schedule(name=name, workflow_id=workflow_id, cron=cron, timezone="Asia/Shanghai", run_class=run_class,
input_template={"stat_date": "{{ schedule.yesterday }}"}, enabled=enabled,
misfire_grace_s=grace, next_run_at=next_run_at)
session.add(row)
await session.commit()
return row.id
async def fires(schedule_id: str) -> list[ScheduleFire]:
async with SessionLocal() as session:
return list((await session.execute(
select(ScheduleFire).where(ScheduleFire.schedule_id == schedule_id).order_by(ScheduleFire.created_at)
)).scalars())
async def runs_of(schedule_id: str) -> list[Run]:
async with SessionLocal() as session:
return list((await session.execute(select(Run).where(Run.schedule_id == schedule_id))).scalars())
async def next_of(schedule_id: str) -> datetime | None:
async with SessionLocal() as session:
return (await session.get(Schedule, schedule_id)).next_run_at
async def settle(run_id: str) -> Run:
for _ in range(400):
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in ("succeeded", "failed", "cancelled") and row.manifest_seq is not None:
await run_manager.wait_idle(run_id)
return row
await asyncio.sleep(0.03)
raise AssertionError(f"运行 {run_id} 停在 {row.status}")
# --------------------------------------------------------------------------
# 到点发起
# --------------------------------------------------------------------------
async def test_due_schedule_launches_once_and_moves_on():
sid = await schedule(await workflow())
await loop.fire_due(NINE + timedelta(seconds=10))
[fire] = await fires(sid)
assert (fire.kind, fire.status, fire.fire_at, fire.reason) == ("scheduled", "started", NINE, None)
assert fire.inputs == {"stat_date": "2026-10-04"}
run = await settle(fire.run_id)
assert run.schedule_id == sid and run.run_class == "formal" and run.status == "succeeded"
assert run.started_by == "定时任务:周一早报"
assert run.input == {"stat_date": "2026-10-04"}
# 正式运行用的是发布版本,不是画布
assert run.version == 1 and run.output == {"r": "发布版 2026-10-04"}
assert await next_of(sid) == NINE + timedelta(days=1)
# 同一拍再来一次:已经推进到明天,不再发起
await loop.fire_due(NINE + timedelta(seconds=20))
assert len(await fires(sid)) == 1 and len(await runs_of(sid)) == 1
async def test_exploratory_schedule_uses_the_canvas():
sid = await schedule(await workflow(published=False), run_class="exploratory")
await loop.fire_due(NINE + timedelta(seconds=5))
[fire] = await fires(sid)
run = await settle(fire.run_id)
assert run.run_class == "exploratory" and run.output == {"r": "画布 2026-10-04"}
async def test_two_concurrent_ticks_launch_only_once():
sid = await schedule(await workflow())
now = NINE + timedelta(seconds=3)
await asyncio.gather(loop.fire_due(now), loop.fire_due(now), loop.fire_due(now))
assert len(await fires(sid)) == 1
[run] = await runs_of(sid)
await settle(run.id)
async def test_concurrent_ticks_with_different_clocks_claim_once():
"""一拍准点、一拍晚了两分钟(会被当成补发、kind 不同、撞不上唯一约束):只有认领到的那一拍处理"""
sid = await schedule(await workflow())
await asyncio.gather(loop.fire_due(NINE + timedelta(seconds=3)), loop.fire_due(NINE + timedelta(minutes=2)))
[fire] = await fires(sid)
[run] = await runs_of(sid)
assert fire.run_id == run.id
await settle(run.id)
async def test_a_fire_already_recorded_is_not_launched_again():
"""认领之外的第二道保险:同一 (任务, fire_at, kind) 已有记录,fire_once 交回那一条,不再发起"""
sid = await schedule(await workflow())
first = await service.fire_once(sid, NINE, "scheduled")
again = await service.fire_once(sid, NINE, "scheduled")
assert again.id == first.id and len(await fires(sid)) == 1
[run] = await runs_of(sid)
await settle(run.id)
async def test_disabled_schedule_is_not_fired():
sid = await schedule(await workflow(), enabled=False)
await loop.fire_due(NINE + timedelta(seconds=5))
assert await fires(sid) == [] and await runs_of(sid) == []
@pytest.mark.parametrize("status", ["queued", "running", "interrupted"])
async def test_previous_run_still_going_means_skip(status):
wf = await workflow()
sid = await schedule(wf)
async with SessionLocal() as session:
busy = Run(workflow_id=wf, workflow_name="门店周报", status=status, schedule_id=sid, run_class="formal")
session.add(busy)
await session.commit()
await loop.fire_due(NINE + timedelta(seconds=5))
[fire] = await fires(sid)
assert (fire.status, fire.run_id) == ("skipped_overlap", None)
assert busy.id[:8] in fire.reason and "跳过" in fire.reason
assert [r.id for r in await runs_of(sid)] == [busy.id]
assert await next_of(sid) == NINE + timedelta(days=1)
# --------------------------------------------------------------------------
# 错过的触发
# --------------------------------------------------------------------------
async def test_missed_within_grace_is_caught_up_once_with_inputs_of_its_own_day():
"""服务停到 00:30(上海 10 月 6 日)才起来:补一次 9 点那一次,昨天还是 10 月 4 日"""
sid = await schedule(await workflow(), grace=86400)
late = NINE + timedelta(hours=15, minutes=30)
await loop.fire_due(late)
[fire] = await fires(sid)
assert (fire.kind, fire.status, fire.fire_at) == ("misfire", "started", NINE)
assert fire.inputs == {"stat_date": "2026-10-04"}
await settle(fire.run_id)
assert await next_of(sid) == NINE + timedelta(days=1)
async def test_backlog_is_skipped_and_only_the_latest_is_caught_up():
"""每小时一次,停了三个多小时:01–03 点的积压记一条跳过,04 点那次在宽限内补发"""
sid = await schedule(await workflow(), cron="0 * * * *", grace=3600)
await loop.fire_due(NINE + timedelta(hours=3, minutes=20))
skipped, caught_up = await fires(sid)
assert (skipped.kind, skipped.status, skipped.fire_at, skipped.run_id) == ("misfire", "skipped_misfire", NINE, None)
assert "2026-10-05 09:00" in skipped.reason and "2026-10-05 11:00" in skipped.reason
assert (caught_up.kind, caught_up.status, caught_up.fire_at) == ("misfire", "started", NINE + timedelta(hours=3))
assert len(await runs_of(sid)) == 1
await settle(caught_up.run_id)
assert await next_of(sid) == NINE + timedelta(hours=4)
async def test_missed_beyond_grace_is_skipped_without_launching():
sid = await schedule(await workflow(), grace=600)
await loop.fire_due(NINE + timedelta(hours=2))
[fire] = await fires(sid)
assert (fire.kind, fire.status, fire.fire_at, fire.run_id) == ("misfire", "skipped_misfire", NINE, None)
assert "10 分钟" in fire.reason and "2026-10-05 09:00" in fire.reason
assert await runs_of(sid) == []
assert await next_of(sid) == NINE + timedelta(days=1)
async def test_days_of_downtime_leave_one_skip_record():
sid = await schedule(await workflow(), grace=600, next_run_at=NINE - timedelta(days=4))
await loop.fire_due(NINE + timedelta(hours=2))
[fire] = await fires(sid)
assert (fire.status, fire.fire_at) == ("skipped_misfire", NINE)
assert "2026-10-01 09:00" in fire.reason and "2026-10-05 09:00" in fire.reason
assert await runs_of(sid) == [] and await next_of(sid) == NINE + timedelta(days=1)
async def test_on_time_fire_ignores_a_small_grace():
"""宽限写 0 也不影响准点的触发:拍与拍之间的十几秒不算错过"""
sid = await schedule(await workflow(), grace=0)
await loop.fire_due(NINE + timedelta(seconds=14))
[fire] = await fires(sid)
assert (fire.kind, fire.status) == ("scheduled", "started")
await settle(fire.run_id)
# --------------------------------------------------------------------------
# 发起被拒、出错
# --------------------------------------------------------------------------
async def test_formal_schedule_without_published_version_records_the_refusal():
wf = await workflow(published=False)
sid = await schedule(wf)
await loop.fire_due(NINE + timedelta(seconds=5))
[fire] = await fires(sid)
async with SessionLocal() as session:
name = (await session.get(Workflow, wf)).name
assert (fire.status, fire.run_id) == ("failed_to_start", None)
assert fire.reason == f"「{name}」还没有发布版本,请先在编排页发布"
assert await runs_of(sid) == []
assert await next_of(sid) == NINE + timedelta(days=1)
async def test_deleted_workflow_records_the_refusal():
sid = await schedule(f"gone{uuid.uuid4().hex[:8]}")
await loop.fire_due(NINE + timedelta(seconds=5))
[fire] = await fires(sid)
assert (fire.status, fire.reason) == ("failed_to_start", "工作流不存在,可能已被删除")
async def test_unexpected_errors_record_a_generic_reason(monkeypatch, caplog):
from app.engine import launch
async def explode(*args, **kwargs):
raise RuntimeError("库连接断开:sqlite busy")
monkeypatch.setattr(launch, "launch_run", explode)
sid = await schedule(await workflow())
with caplog.at_level(logging.ERROR):
await loop.fire_due(NINE + timedelta(seconds=5))
[fire] = await fires(sid)
assert (fire.status, fire.reason) == ("failed_to_start", service.GENERIC_REASON)
assert "库连接断开" in caplog.text
assert await next_of(sid) == NINE + timedelta(days=1)
async def test_broken_cron_in_the_database_stops_the_schedule():
sid = await schedule(await workflow(), cron="0 25 * * *")
await loop.fire_due(NINE + timedelta(seconds=5))
[fire] = await fires(sid)
assert fire.status == "failed_to_start" and "「时」" in fire.reason
assert await next_of(sid) is None
# --------------------------------------------------------------------------
# start / stop
# --------------------------------------------------------------------------
async def test_start_does_nothing_when_switched_off():
"""测试环境 AGENTLAB_SCHEDULER=0(conftest):start 不起循环,stop 照样能调"""
assert await scheduler.start() is False
assert scheduler.running() is False
await scheduler.stop()
async def test_loop_ticks_until_stopped():
sid = await schedule(await workflow(), next_run_at=datetime.now(UTC) - timedelta(seconds=1), cron="* * * * *")
on = Settings(_env_file=None, scheduler=True)
assert await scheduler.start(interval=0.05, config=on) is True
assert scheduler.running()
for _ in range(200):
if await fires(sid):
break
await asyncio.sleep(0.02)
await scheduler.stop()
assert scheduler.running() is False
[fire] = await fires(sid)
assert fire.status == "started"
await settle(fire.run_id)
# 停下之后不再有拍:把下一次拨回过去也不会再发起
async with SessionLocal() as session:
await session.execute(Schedule.__table__.update().where(Schedule.id == sid)
.values(next_run_at=datetime.now(UTC) - timedelta(seconds=1)))
await session.commit()
await asyncio.sleep(0.2)
async with SessionLocal() as session:
assert await session.scalar(select(func.count()).select_from(ScheduleFire)
.where(ScheduleFire.schedule_id == sid)) == 1
async def test_start_twice_keeps_one_loop():
on = Settings(_env_file=None, scheduler=True)
await scheduler.start(interval=0.05, config=on)
first = loop.runner.task
await scheduler.start(interval=0.05, config=on)
assert loop.runner.task is first
await scheduler.stop()
assert first.done()