Files
AgentLab/backend/app/scheduler/service.py
T
ThearchyHelios a9d7cba884 定时任务复核修正(第 1 轮):重启挂起的运行不再让任务静默停摆,格式选错、地址发不出去时照实报,整份回写不再丢掉到点的触发
- 挂起:上一次运行在服务重启时被挂起(interrupted、没有待审批)不会自己接着执行,之后每次触发都按重叠跳过,原因写
  「还没有结束」,一条通知也不推,任务就此静默停摆。interrupted 照规格仍算没结束,但跳过原因按挡路的运行分三种写
  (仍在进行中、正在等待审批、已挂起并请在「记录」中继续运行或放弃),运行编号写成界面上的 #前 6 位,去掉「按设置」;
  挂起的那次按 failed 事件推一次,写明原因和处理办法,不是每次触发都推。停在审批时每停一个审批点推一次(已处理状态
  带上第几段执行,以前第二个审批点不再推);审批刚批完、还没接着执行(answered)不当挂起
- 没有结束时间的失败运行(服务重启时还在排队、被改成 failed)永远不会封存:等封存改按最后更新时间算,以前按「现在」
  等,每一拍都等、永远不推
- 平台错误码按地址的主机认,不看所选格式:格式和地址对不上时,测试显示「已送达」、正式推送记成功,群里却收不到;
  现在记失败并带上平台的错误码。群机器人的地址(飞书、企业微信、钉钉)保存时要配它平台的格式,否则 422;格式省略时
  按地址认。只在地址或格式真改了时查,库里已有的旧设置不挡停用、改名
- 地址校验:端口不是 1 到 65535 的数字、夹着空格或不可见字符(零宽空格、控制字符)、httpx 建不了请求的(主机名不合
  IDNA 规则)保存时就回 422 和中文说明;推送时另兜住 InvalidURL 和其他异常,测试接口不再回 500 或英文报错;端口写错
  的地址也照样取出主机名
- PATCH 看值变没变,不看键在不在:整份表单回写不再重新校验没改的工作流(工作流删了也能停用),cron、时区、启用状态
  真改了才重算下一次触发。启用着改 cron、时区时,从原来那个到点的时刻接着算,新表达式也覆盖那一刻就还发起这一次。
  补两人同时保存的测试,证明条件 UPDATE 守住了乐观锁
- 发起中的占位:接口读到正在发起的触发按「已发起」给(run_id 为空),不再闪一次「未能发起:服务在发起运行前停止」;
  库里仍先写占位,进程被强杀时照实说没有发起
- stop:先置停止标志再取任务,这一拍收尾不再新起通知扫描(以前会落下一个不在等待名单里的通知任务);循环和通知两件
  事并行等,宽限缩到 3 秒和 1.5 秒,给关停时的 run_manager.shutdown 留出 docker stop 的大半时间
- 通知正文:立即运行写「手动触发」,补发标明「错过后补发」;失败不再写「原因:运行失败,详情请查看运行记录」,改写
  失败节点的名字和处理办法;状态叫法和界面一致(等待审批,已挂起,可继续运行);「封存哈希」改叫「清单哈希」,运行
  编号写 #前 6 位;测试消息只说收到它证明得了的事(企业微信、没配密钥时不提签名)。测试接口的 message 改成 ok、
  http_status 之外的补充说明(送达时提醒去接收端确认,失败时是原因),前端接在「测试消息已送达(HTTP 200)」后面不再重复
- 422 文案:不回显 Python 的值(None 写「未填写」,列表不原样回显、点出不认识的那一项),格式、事件写中文名;
  copy-allow.json 为平台名「钉钉」加一条(和前端包为 terms.ts 加的那条并存,试合并不冲突)
- test_loop_ticks_until_stopped:下一次放在整分上、只认发起的那一条,去掉约 1/60 的偶发失败
- 逐个做过变异,改回后对应测试都变红:挂起不推、跳过原因退回旧写法、没结束时间按现在等、错误码只看格式、去掉版本
  条件、到点保留和值比较一起去掉、不屏蔽发起中的触发、stop 既不查停止标志也不兜底、answered 当挂起、审批状态不带段号、
  不建请求校验地址、保存时不查格式
2026-10-05 10:00:25 +08:00

375 lines
19 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.
"""定时任务的业务规则:输入模板、和工作流入口字段的对照、发起一次触发。
调度循环(loop.py)到点调 fire_once,接口的「立即运行」也调它:重叠跳过、渲染输入、记触发、发起运行这一套
只有一份。发起本身走 engine/launch.py 的 launch_run,和 HTTP 发起是同一套规则——正式运行只能从当前发布版本
发起,这一条不会在定时任务这边被绕过。
"""
from __future__ import annotations
import asyncio
import logging
import re
import weakref
from datetime import date, datetime, timedelta, timezone
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from app.db.base import SessionLocal, utcnow
from app.db.models import Approval, Run, Schedule, ScheduleFire
from app.engine.expressions import _FILTERS, render_template
from app.scheduler import cron
logger = logging.getLogger(__name__)
#: 变量清单的示例按这个时区算(GET /api/schedules/variables 没带时区时)
DEFAULT_TIMEZONE = "Asia/Shanghai"
RUN_CLASSES = ("formal", "exploratory")
#: 这几个状态的运行算「还没结束」,挡住这个任务之后的触发。interrupted 也算(规格如此):停在审批上的,批完会
#: 接着执行;服务重启挂起的却不会自己接着执行,要有人去继续或放弃——所以跳过的原因分开写(见 overlap_reason),
#: 挂起的另外按失败推一次通知(webhook._state_of),不然任务就此静默停摆
ACTIVE_RUN = ("queued", "running", "interrupted")
#: 模板里能用的变量:(变量名, 说明)。说明写在变量清单接口里,和示例一起给界面
VARIABLES: tuple[tuple[str, str], ...] = (
("today", "今天(YYYY-MM-DD)"),
("yesterday", "昨天(YYYY-MM-DD)"),
("this_week_start", "本周一(YYYY-MM-DD)"),
("last_week_start", "上周一(YYYY-MM-DD)"),
("last_week_end", "上周日(YYYY-MM-DD)"),
("this_month_start", "本月 1 日(YYYY-MM-DD)"),
("last_month_start", "上月 1 日(YYYY-MM-DD)"),
("last_month_end", "上月最后一天(YYYY-MM-DD)"),
("fire_time", "本次计划触发时间(带时区偏移的 ISO 时间)"),
("last_fire_time", "上一次计划触发时间(带时区偏移的 ISO 时间)"),
)
_NAMES = tuple(f"schedule.{key}" for key, _ in VARIABLES)
_TEMPLATE_RE = re.compile(r"\{\{(.*?)\}\}", re.DOTALL)
#: 触发记下来、运行还没发起时先写成这样:发起成功再改成 started。进程在这中间被强杀,留下的记录照实说没有发起。
#: 进程还在、只是还没发起完的,接口按「已发起」给(见 launching)
INTERRUPTED_REASON = "服务在发起运行前停止,这次触发没有发起运行"
#: launch_run 抛了 LaunchRefused 以外的异常(库出错之类)。详细信息只进日志
GENERIC_REASON = "发起运行时出错,详细信息见服务日志"
class TemplateError(ValueError):
"""输入模板写错了。str() 是给人看的中文说明,指出是哪个输入。"""
def current_value(value: Any) -> str:
"""报错末尾回显当前值的那半句:没填写说「当前未填写」,文本、数字照原样,true / false 按 JSON 的写法;列表、对象
不回显——给人看的说明里不出现 Python 的写法(None、['a', 'b'])。"""
if value is None or (isinstance(value, str) and not value.strip()):
return ",当前未填写"
if isinstance(value, bool):
return f",当前为「{'true' if value else 'false'}」"
if isinstance(value, (str, int, float)):
return f",当前为「{value}」"
return ""
# --------------------------------------------------------------------------
# 模板变量
# --------------------------------------------------------------------------
def template_values(fire_at: datetime, tz: str, cron_expr: str | cron.CronSpec) -> dict[str, str]:
"""按计划触发时间在任务时区算出的变量(键不带 schedule. 前缀)。日期写 YYYY-MM-DD,时间写带偏移的 ISO、只到秒。
按 fire_at 算而不是按「现在」:补发晚了一小时、立即运行在零点前后点下去,「昨天」都是那次触发的昨天。
last_fire_time 是按 cron 往前找的上一次计划触发,不看历史记录:同一个 (cron, 时区, fire_at) 永远渲染出同一份输入。
"""
zone = cron.zone(tz)
local = fire_at.astimezone(zone)
today = local.date()
week_start = today - timedelta(days=today.weekday())
month_start = today.replace(day=1)
last_month_end = month_start - timedelta(days=1)
previous = cron.prev_fire(cron_expr, zone, fire_at)
return {
"today": _day(today),
"yesterday": _day(today - timedelta(days=1)),
"this_week_start": _day(week_start),
"last_week_start": _day(week_start - timedelta(days=7)),
"last_week_end": _day(week_start - timedelta(days=1)),
"this_month_start": _day(month_start),
"last_month_start": _day(last_month_end.replace(day=1)),
"last_month_end": _day(last_month_end),
"fire_time": local.isoformat(timespec="seconds"),
"last_fire_time": previous.astimezone(zone).isoformat(timespec="seconds") if previous else "",
}
def _day(value: date) -> str:
return value.isoformat()
def render_inputs(template: dict[str, str], values: dict[str, str]) -> dict[str, str]:
"""渲染输入模板。用项目通用的 {{ }} 渲染(engine/expressions.render_template),变量挂在 schedule 下。"""
ctx = {"schedule": values}
return {name: render_template(text, ctx) for name, text in template.items()}
def check_template(template: Any) -> dict[str, str]:
"""校验输入模板,原样返回(None 当空模板)。写错抛 TemplateError。
项目的模板渲染取不到值时渲染成空串、不报错——画布上的半成品图要能运行。定时任务没人盯着,拼错的变量名
会让每一次运行都拿到空的日期,所以保存和预览时就要说出来。
"""
if template is None:
return {}
if not isinstance(template, dict):
raise TemplateError("输入模板需要是「字段名:模板」的对象,例如 stat_date 对应 {{ schedule.yesterday }}")
out: dict[str, str] = {}
for raw, text in template.items():
# 字段名去掉首尾空白再存:「 stat_date」交给运行,入口节点认不出它,必填项照样报缺
name = raw.strip() if isinstance(raw, str) else ""
if not name:
raise TemplateError("输入模板里有空的字段名,请填写工作流的输入字段名")
if name in out:
raise TemplateError(f"输入模板里字段「{name}」写了两次,请只保留一个")
if not isinstance(text, str):
raise TemplateError(f"输入「{name}」的值需要是文本(可以带 {{{{ schedule.yesterday }}}} 这样的变量)"
f"{current_value(text)}")
_check_text(name, text)
out[name] = text
return out
def _check_text(name: str, text: str) -> None:
for match in _TEMPLATE_RE.finditer(text):
expr = match.group(1).strip()
if not expr:
raise TemplateError(f"输入「{name}」的模板里有空的 {{{{ }}}},请在中间写变量名,例如 schedule.yesterday")
path, *filters = (part.strip() for part in expr.split("|"))
if path not in _NAMES:
raise TemplateError(f"输入「{name}」的模板里「{path}」不是可用的变量,可用的变量:{'、'.join(_NAMES)}")
for filt in filters:
if filt not in _FILTERS:
raise TemplateError(f"输入「{name}」的模板里「{filt}」不是可用的过滤器,可用的过滤器:{'、'.join(_FILTERS)}")
rest = _TEMPLATE_RE.sub("", text)
if "{{" in rest or "}}" in rest:
raise TemplateError(f"输入「{name}」的模板里 {{{{ 和 }}}} 没有成对出现,请检查是否少写了一半")
def variables(now: datetime | None = None, tz: str = DEFAULT_TIMEZONE) -> list[dict[str, str]]:
"""变量清单:[{name, label, example}]。示例按 now(默认现在)这一刻触发算;上一次计划触发时间没有 cron
可依,示例按每天同一时刻触发算。"""
moment = (now or utcnow()).replace(second=0, microsecond=0)
local = moment.astimezone(cron.zone(tz))
daily = f"{local.minute} {local.hour} * * *"
values = template_values(moment, tz, daily)
return [{"name": f"schedule.{key}", "label": label, "example": values[key]} for key, label in VARIABLES]
# --------------------------------------------------------------------------
# 和工作流入口字段对照
# --------------------------------------------------------------------------
def input_fields(graph: Any) -> list[dict[str, Any]]:
"""工作流入口节点声明的字段 [{name, required, default}],没声明(或图不成形)是空列表。"""
from app.engine.schema import GraphSpec, NodeType
try:
spec = GraphSpec.model_validate(graph)
except Exception: # noqa: BLE001 - 图不成形交给发起时报
return []
out: list[dict[str, Any]] = []
for node in spec.nodes:
if node.type != NodeType.INPUT:
continue
for field in node.config.get("fields") or []:
if isinstance(field, dict) and isinstance(field.get("name"), str) and field["name"].strip():
out.append({"name": field["name"].strip(), "required": bool(field.get("required")),
"default": field.get("default")})
return out
def check_fields(template: dict[str, str], fields: list[dict[str, Any]], workflow_name: str, *,
require: bool = True) -> None:
"""入口声明了字段时,模板里的字段名要在里面;require 时必填且没有默认值的字段要有模板。不对抛 TemplateError。
入口没声明字段的工作流,输入是开放的(运行时传什么键都收),不查。预览时用户还在填,只查多出来的名字。
"""
if not fields:
return
names = [f["name"] for f in fields]
unknown = [name for name in template if name not in names]
if unknown:
raise TemplateError(f"输入「{unknown[0]}」不是工作流「{workflow_name}」入口声明的字段,可用的字段:{'、'.join(names)}")
if not require:
return
missing = [f["name"] for f in fields
if f["required"] and f["default"] in (None, "") and f["name"] not in template]
if missing:
raise TemplateError(f"工作流「{workflow_name}」的必填输入「{'、'.join(missing)}」没有填写,"
"到点发起的运行会因缺少输入而失败")
# --------------------------------------------------------------------------
# 发起一次触发
# --------------------------------------------------------------------------
#: 每个事件循环一把锁:发起(查重叠 → 记触发 → 发起运行 → 改记录)和通知扫描查库互斥。单进程部署,进程内
#: 互斥就够;按事件循环分开是因为测试每个用例一个新循环,跨循环用同一把 asyncio.Lock 会报错
_LOCKS: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock]" = weakref.WeakKeyDictionary()
def launch_lock() -> asyncio.Lock:
loop = asyncio.get_running_loop()
lock = _LOCKS.get(loop)
if lock is None:
lock = _LOCKS[loop] = asyncio.Lock()
return lock
#: 正在发起的触发:占位记录已写进库、launch_run 还没返回。通知扫描有 launch_lock 挡着,接口的读(任务列表的
#: last_fire、触发历史)没有:它们据此按「已发起」给,不把还在发起的说成「未能发起」(单进程部署,进程内的集合就够)
_LAUNCHING: set[str] = set()
def launching(fire_id: str) -> bool:
return fire_id in _LAUNCHING
def actor_label(name: str) -> str:
"""定时任务发起的运行记在谁名下。"""
return f"定时任务:{name}"[:100]
def next_run(cron_expr: str, tz: str, enabled: bool, now: datetime | None = None) -> datetime | None:
"""下一次触发的时刻(UTC);停用的任务没有下一次。"""
if not enabled:
return None
return cron.next_fire(cron_expr, tz, now or utcnow())
async def fire_once(schedule_id: str, fire_at: datetime, kind: str, *, actor: str | None = None,
) -> ScheduleFire | None:
"""处理一次触发,返回记下的触发记录。同一 (任务, fire_at, kind) 已经有记录(两拍撞在一起、立即运行连点两下)
时不再发起,返回已有的那一条;任务不存在返回 None。
上一次运行还没结束就记 skipped_overlap;输入渲染不了、launch_run 拒绝或出错记 failed_to_start(拒绝写原话,
其他异常写通用说明、详细信息进日志);发起成功记 started 和运行 id。
"""
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
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
_LAUNCHING.add(fire.id)
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)
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:
"""记一次错过、没有补发的触发(kind=misfire,status=skipped_misfire)。"""
return await _record(schedule_id, fire_at, "misfire", "skipped_misfire", reason, {})
async def _active_run(schedule_id: str) -> tuple[str, str, bool] | None:
"""这个任务发起的、还没结束的运行(最近的那一个):(运行 id, 状态, 是否停在审批),没有返回 None。
停在审批:有 pending 的审批,或者刚批完(answered)、还没接着执行。interrupted 而两样都没有的是挂起的。"""
async with SessionLocal() as session:
row = (await session.execute(
select(Run.id, Run.status).where(Run.schedule_id == schedule_id, Run.status.in_(ACTIVE_RUN))
.order_by(Run.created_at.desc()).limit(1))).first()
if row is None:
return None
run_id, status = row
waiting = status == "interrupted" and bool(await session.scalar(
select(func.count()).select_from(Approval).where(
Approval.run_id == run_id, Approval.status.in_(("pending", "answered")))))
return run_id, status, waiting
def overlap_reason(run_id: str, status: str, waiting: bool) -> str:
"""跳过的原因按挡路的那次运行说清楚该等还是该处理。运行编号和界面上的短编号一样写(#前 6 位)。"""
tag = f"#{run_id[:6]}"
if status != "interrupted":
return f"上一次运行 {tag} 仍在进行中,本次触发已跳过"
if waiting:
return f"上一次运行 {tag} 正在等待审批,处理审批、运行结束后才会按时发起新的运行;本次触发已跳过"
return (f"上一次运行 {tag} 已挂起(通常是服务重启所致),请在「记录」中继续运行或放弃这次运行,"
"它结束后才会按时发起新的运行;本次触发已跳过")
async def _record(schedule_id: str, fire_at: datetime, kind: str, status: str, reason: str | None,
inputs: dict[str, Any], *, fresh: bool = False) -> ScheduleFire | None:
"""插入一条触发记录。撞上唯一约束(同一次触发已经记过)或任务已被删除(外键)时:fresh 返回 None,否则返回
已有的那一条(没有就是 None)。"""
fire = ScheduleFire(schedule_id=schedule_id, fire_at=fire_at, kind=kind, status=status, reason=reason,
inputs=inputs)
async with SessionLocal() as session:
session.add(fire)
try:
await session.commit()
except IntegrityError:
await session.rollback()
return None if fresh else await _existing(schedule_id, fire_at, kind)
await session.refresh(fire)
return fire
async def _existing(schedule_id: str, fire_at: datetime, kind: str) -> ScheduleFire | None:
async with SessionLocal() as session:
return await session.scalar(select(ScheduleFire).where(
ScheduleFire.schedule_id == schedule_id, ScheduleFire.fire_at == fire_at, ScheduleFire.kind == kind))
async def _finish(fire_id: str, *, status: str, reason: str | None, run_id: str | None = None) -> ScheduleFire | None:
async with SessionLocal() as session:
await session.execute(update(ScheduleFire).where(ScheduleFire.id == fire_id)
.values(status=status, reason=reason, run_id=run_id))
await session.commit()
return await session.get(ScheduleFire, fire_id)
def local_text(moment: datetime, tz: str) -> str:
"""给人看的本地时间「2026-10-05 09:00」,写进跳过原因、通知里。"""
return moment.astimezone(cron.zone(tz)).strftime("%Y-%m-%d %H:%M")
def duration_text(seconds: int) -> str:
"""补发宽限这类秒数的说法:3600 →「1 小时」,600 →「10 分钟」,90 →「90 秒」。"""
if seconds and seconds % 3600 == 0:
return f"{seconds // 3600} 小时"
if seconds and seconds % 60 == 0:
return f"{seconds // 60} 分钟"
return f"{seconds} 秒"
def as_utc(value: datetime) -> datetime:
return value.astimezone(timezone.utc) if value.tzinfo else value.replace(tzinfo=timezone.utc)