fix(automation): 调度心跳与作业周期解耦,修掉系统性跳轮
线上现象:同一页面上「CK 自动巡检」的最近运行是 21:45:34,而「下次运行」显示 21:50:34 且已经过去了 —— 巡检明明只要 19 秒,却总是等在那里。 根因是把心跳周期直接当成了作业周期:TICK_SECONDS=300 既是「循环多久醒一次」, 又被写进 next_run_at = 作业结束 + 300s。两者同频会系统性跳轮: 心跳 t=0 ck_health 19 秒跑完 → next = 5:19 心跳 t=5:00 差 19 秒没到点 → 整轮空转,什么都不做 心跳 t=10:00 才真正跑到 于是作业实际是每 10 分钟一轮,而不是设计的 5 分钟;只要作业耗时不是刚好 0, 就会稳定地"差一点点"被跳过。两个作业共享同一个心跳时,还会互相把对方的到期 时刻推后。 改动: - 拆成两个常量:TICK_SECONDS=30(心跳,只做「有没有到点的作业」检查,几乎不 耗资源)、JOB_INTERVAL_SECONDS=300(作业周期,跑完一轮后隔多久再跑)。 - next_run_at 改用 JOB_INTERVAL_SECONDS。 - /api/automation/jobs 的 scheduler 同时暴露两个值;启动日志一并打印。 - 页面:「单账号重检间隔」加 tooltip 说明它不决定调度频率(它只控制同一行多久 重检/重试),避免和调度周期混淆。 回归测试(3 条):心跳必须比作业周期密至少 4 倍;周期未到时连跳多次心跳都不跑; 把 next_run_at 拨到过去后下一次心跳必须跑到。 全量 304 通过。
This commit is contained in:
@@ -35,7 +35,17 @@ from ...models import AutomationJob, AutomationRun
|
||||
# 分开的原因:全库 12,881 条的 CK 若想「6 小时内都被查一遍」,需要约 36 条/分钟的
|
||||
# 吞吐;按 5 分钟一轮、单轮 200 条算 = 40 条/分钟,刚好够。若把作业周期也设成 6h,
|
||||
# 就变成 200 条/6h = 800 条/天,全库要 16 天才能过一轮,等于没巡检。
|
||||
TICK_SECONDS = 300
|
||||
# 心跳间隔:只用来「看一眼有没有到点的作业」,几乎不耗资源,所以设得比作业
|
||||
# 周期密得多。作业周期由各自的 next_run_at(= 自己结束时刻 + JOB_INTERVAL)决定。
|
||||
#
|
||||
# 这里曾经把心跳周期直接当作业周期用(心跳 300s + next_run_at = 结束 + 300s),
|
||||
# 两者同频会导致系统性跳轮:作业 19 秒跑完 → next = +5 分钟,而下一次心跳正好
|
||||
# 在 5 分钟整,常常差几十秒没到点,于是整轮空转,实际变成每 10 分钟才跑一次。
|
||||
# 表现就是「检测明明只要 19 秒,却要排队等很久」。
|
||||
TICK_SECONDS = 30
|
||||
|
||||
# 作业自身的运行周期:跑完一轮后隔多久再跑下一轮。
|
||||
JOB_INTERVAL_SECONDS = 300
|
||||
|
||||
TRUTHY = {"1", "true", "yes", "on"}
|
||||
|
||||
@@ -199,7 +209,7 @@ def _run_one(db: Session, job: AutomationJob, spec: JobSpec) -> dict[str, Any]:
|
||||
job.last_run_at = finished
|
||||
# 下一轮固定在一个 tick 之后;interval_seconds 是「同一行多久重检」,
|
||||
# 由作业自己在选行时用,不参与调度。
|
||||
job.next_run_at = finished + timedelta(seconds=TICK_SECONDS)
|
||||
job.next_run_at = finished + timedelta(seconds=JOB_INTERVAL_SECONDS)
|
||||
job.last_status = status
|
||||
job.last_message = message
|
||||
job.updated_at = finished
|
||||
@@ -246,7 +256,9 @@ def _loop() -> None:
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
logger.info("自动化调度器已启动,tick={}s", TICK_SECONDS)
|
||||
logger.info(
|
||||
"自动化调度器已启动:心跳 {}s,作业周期 {}s", TICK_SECONDS, JOB_INTERVAL_SECONDS
|
||||
)
|
||||
while not _stop_event.wait(TICK_SECONDS):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -291,5 +303,6 @@ def scheduler_status() -> dict[str, Any]:
|
||||
"enabled": automation_enabled(),
|
||||
"running": _thread is not None and _thread.is_alive(),
|
||||
"tick_seconds": TICK_SECONDS,
|
||||
"job_interval_seconds": JOB_INTERVAL_SECONDS,
|
||||
"registered": sorted(_REGISTRY),
|
||||
}
|
||||
|
||||
@@ -877,7 +877,10 @@ export interface AutomationJobItem {
|
||||
export interface AutomationSchedulerStatus {
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
/** 调度心跳(轮询间隔),比作业周期密,只做「有没有到点」的检查。 */
|
||||
tick_seconds: number;
|
||||
/** 作业周期:同一个作业跑完一轮后隔多久再跑下一轮。 */
|
||||
job_interval_seconds: number;
|
||||
registered: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -312,6 +312,7 @@ export default function AutomationPage() {
|
||||
<Form.Item
|
||||
name={[job.key, "interval_hours"]}
|
||||
label="单账号重检间隔(小时)"
|
||||
tooltip="同一个账号多久重新检测/重试一次。它不决定调度频率 —— 调度周期见页面顶部说明。"
|
||||
>
|
||||
<InputNumber min={0.1} step={1} disabled={!canManage} />
|
||||
</Form.Item>
|
||||
|
||||
Reference in New Issue
Block a user