perf(automation): 调度器改为**作业之间并发**,慢作业不再挡住快作业

用户要求做这条(原本串行)。串行的代价现在很明显:新加的「账号资料定时补全」
一轮要 4 分钟以上,而源码注释里写的是「目前只有 CK 巡检,单轮约 20~40 秒」——
那 4 分钟里 CK 巡检/重登只能干等。

## 两处保护(缺一不可)

**① 在途守卫**(并发化之后最容易出的事故)

`next_run_at` 是作业**跑完之后**才更新的,所以一个跑得比 tick 还久的作业,
下一个 tick 看它仍然是「到期」状态 —— 没有守卫就会被**再派发一次**,
等于把速率翻倍(对斗鱼接口就是"并发放开",正是风控最敏感的地方)。
所以用 `_IN_FLIGHT` 记录在跑的作业 key,且**成功/失败都必须清标记**
(否则该作业以后再也不跑了)。两条都有用例钉住。

**② 独立 session**

SQLAlchemy 的 Session **不是线程安全的**。作业现在真的并行了,
所以每个作业在自己的 `SessionLocal()` 里跑,按 key 重新取行
(调度线程里那行可能已经过期)。共用 session 会互相踩事务。

## tick 不再被拖住

调度循环改用 `run_due_jobs(db, wait=False)`:**派发完就返回**,
tick 保持 5 分钟一轮,不再等作业跑完。

`wait=True`(默认)保留原语义:顺序执行、用调用方的 session 并返回 outcomes ——
测试与手动触发走这条,所以既有的 33 条调度器用例**一条没改**。

## ⚠️ 顺带纠正我自己上一轮的一个错误说法

我上一条说「interval_seconds=10 分钟」会生效 —— **不对**。`_run_one` 里写着:

    下一轮固定在一个 tick 之后;interval_seconds 是「同一行多久重检」,
    由作业自己在选行时用,**不参与调度**。

也就是下一轮永远是「跑完 + 5 分钟」。所以「账号资料定时补全」的真实节奏是
`4.4 分钟跑 + 5 分钟等 ≈ 9.4 分钟一轮` ≈ 2,553 个/小时 ≈ **3.05 小时**,
和之前估的 3.2 小时吻合。

## 测试(+4)

- `wait=False` 立刻返回、作业在后台真的开始了
- **在途作业不会被重复派发**(速率翻倍那个事故的守卫)
- 失败后仍会清在途标记、作业能再跑(别被永久卡死)
- `_release` 即使对不存在的 key 也不炸

## 验证

TZ=Asia/Shanghai **636 passed / 6 skipped**;ruff 全绿。
This commit is contained in:
yml2213
2026-10-04 21:24:35 +08:00
parent 0d60cfbec9
commit aea8e4f73d
2 changed files with 235 additions and 9 deletions
+124
View File
@@ -16,6 +16,8 @@
> 新代码不再读写,测试也不许再往上面写值。
"""
import threading
import time
from datetime import UTC, datetime, timedelta
import pytest
@@ -31,6 +33,7 @@ from web.backend.services.automation import (
reset_registry,
run_due_jobs,
run_job_now,
scheduler,
)
from web.backend.services.automation.jobs import ck_health
@@ -792,3 +795,124 @@ class TestHeartbeatDecoupledFromInterval(_Base):
job.next_run_at = datetime.now(UTC) - timedelta(seconds=1)
self.session.commit()
return job
class TestSchedulerRunsJobsConcurrently:
"""作业之间**并发**跑 —— 慢作业不该让快作业干等。
以前是串行(源码注释:「作业串行执行 —— 目前只有 CK 巡检,单轮约 20~40 秒」)。
但「账号资料定时补全」一轮要 4 分钟以上,串行会把 CK 巡检饿住。
调度循环用 `wait=False`:**派发完就返回**,tick 不再被慢作业拖住。
"""
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
reset_registry()
def teardown_method(self):
reset_registry()
self.session.close()
def _job(self, key: str, run, *, enabled: bool = True):
register_job(
JobSpec(
key=key,
title=key,
interval_seconds=3600,
batch_size=1,
concurrency=1,
run=run,
)
)
self.session.add(
AutomationJob(
key=key,
title=key,
interval_seconds=3600,
batch_size=1,
concurrency=1,
enabled=enabled,
)
)
self.session.commit()
def test_wait_false_returns_without_blocking(self):
"""`wait=False` 必须**立刻返回**,不等作业跑完。"""
started = threading.Event()
release = threading.Event()
def slow(db, job):
started.set()
release.wait(timeout=5)
return {"scanned": 1}
self._job("slow", slow)
outcomes = run_due_jobs(self.session, wait=False)
try:
assert outcomes == [], "wait=False 不该返回 outcomes(它不等结果)"
assert started.wait(timeout=5), "作业应已在后台开始"
finally:
release.set()
# 等在途标记清掉,避免污染后续用例
for _ in range(50):
if not scheduler._IN_FLIGHT:
break
time.sleep(0.1)
def test_in_flight_job_is_not_dispatched_twice(self):
"""**在途守卫**:同一个作业不会重复派发。
这是并发化之后最容易出的事故:`next_run_at` 要等作业跑完才更新,
所以一个跑得比 tick 还久的作业,下一个 tick 看它仍然是「到期」——
没有守卫就会被**再派发一次**,等于把速率翻倍
(对斗鱼接口就是"并发放开",正是风控最敏感的地方)。
"""
calls: list[int] = []
release = threading.Event()
entered = threading.Event()
def blocking(db, job):
calls.append(1)
entered.set()
release.wait(timeout=5)
return {}
self._job("blocking", blocking)
run_due_jobs(self.session, wait=False)
try:
assert entered.wait(timeout=5)
# 第一次还在跑,再触发一轮 —— 不该被重复派发
run_due_jobs(self.session, wait=False)
time.sleep(0.3)
assert len(calls) == 1, "在途作业被重复派发了"
finally:
release.set()
for _ in range(50):
if not scheduler._IN_FLIGHT:
break
time.sleep(0.1)
def test_release_clears_marker_even_on_failure(self):
"""失败的作业也必须清掉在途标记,否则它以后**再也不会跑**。"""
scheduler._IN_FLIGHT.add("ghost")
scheduler._release("ghost")
assert "ghost" not in scheduler._IN_FLIGHT
def test_failed_job_is_released_and_can_run_again(self):
"""作业抛异常后仍要能被下一轮再派发(别把它永久卡死在"在途")。"""
calls: list[int] = []
def boom(db, job):
calls.append(1)
raise RuntimeError("接口挂了")
self._job("boom", boom)
run_due_jobs(self.session, wait=False)
for _ in range(50):
if not scheduler._IN_FLIGHT:
break
time.sleep(0.1)
assert not scheduler._IN_FLIGHT, "失败后没清在途标记,作业会被永久跳过"
assert calls, "作业应该真的跑过一次"
+111 -9
View File
@@ -16,6 +16,7 @@ from __future__ import annotations
import os
import threading
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -63,6 +64,19 @@ class JobSpec:
_REGISTRY: dict[str, JobSpec] = {}
#: 同时最多几个作业在跑。作业之间互不依赖,并行是为了**慢作业不挡住快作业**
#: (以前串行:一个作业跑 4 分钟,CK 巡检就得等 4 分钟)。
_JOB_MAX_WORKERS = 4
_EXECUTOR: ThreadPoolExecutor | None = None
_EXECUTOR_LOCK = threading.Lock()
#: **在途守卫**:正在跑的作业 key。
#: 必须要有 —— `next_run_at` 是在作业**跑完之后**才更新的,所以一个跑得比 tick
#: 还久的作业,下一个 tick 看它仍然是「到期」状态,会被重复派发,
#: 等于把速率翻倍(对斗鱼接口来说就是"并发放开",正是风控最敏感的地方)。
_IN_FLIGHT: set[str] = set()
_IN_FLIGHT_LOCK = threading.Lock()
_thread: threading.Thread | None = None
_stop_event = threading.Event()
_lock = threading.Lock()
@@ -151,32 +165,113 @@ def cleanup_orphan_runs(db: Session, message: str = "服务重启中断") -> int
return len(orphans)
def run_due_jobs(db: Session, *, now: datetime | None = None) -> list[dict[str, Any]]:
"""跑一轮「到点的作业」,返回每轮结果。
def _get_executor() -> ThreadPoolExecutor:
global _EXECUTOR
with _EXECUTOR_LOCK:
if _EXECUTOR is None:
_EXECUTOR = ThreadPoolExecutor(
max_workers=_JOB_MAX_WORKERS, thread_name_prefix="automation-job"
)
return _EXECUTOR
抽成普通函数是为了可测:测试直接调用即可,不必启动线程或等 60 秒。
作业串行执行 —— 目前只有 CK 巡检,单轮约 20~40 秒,远小于 tick,串行
反而省掉了并发调度的复杂度。
def _run_job_isolated(job_key: str) -> dict[str, Any] | None:
"""在**自己的 session** 里跑一个作业。
必须独立 session:SQLAlchemy 的 Session **不是线程安全的**,
多个作业共用一个 session 会互相踩事务(而他们现在是真的并行了)。
重新按 key 取行,是因为调度线程里的那行可能已经过期。
"""
db = SessionLocal()
try:
job = db.query(AutomationJob).filter(AutomationJob.key == job_key).first()
spec = _REGISTRY.get(job_key)
if job is None or spec is None:
return None
return _run_one(db, job, spec)
finally:
db.close()
def run_due_jobs(
db: Session, *, now: datetime | None = None, wait: bool = True
) -> list[dict[str, Any]]:
"""跑一轮到期的作业。
**作业之间并发**(以前是串行):慢作业不该让快作业干等。
两个正交的保护:
- **在途守卫**:同一个作业不会重复派发(`next_run_at` 要等跑完才更新,
否则跑得比 tick 久的作业每个 tick 都会被再派发一次 = 速率翻倍)
- `wait=False`(调度循环用):**派发完就返回**,tick 不被慢作业拖住;
`wait=True`(默认,测试与手动触发用):跑完并返回 outcomes
⚠️ 每**行**多久重检由作业自己按 `interval_seconds` 决定(见 `_run_one` 的注释),
这里的 `next_run_at` 固定是「跑完 + 一个 tick」。
"""
now = now or datetime.now(UTC)
outcomes: list[dict[str, Any]] = []
jobs = (
db.query(AutomationJob)
.filter(AutomationJob.enabled.is_(True))
.order_by(AutomationJob.key.asc())
.all()
)
dispatched: list[str] = []
for job in jobs:
spec = _REGISTRY.get(job.key)
if spec is None or not _due(job, now):
continue
outcomes.append(_run_one(db, job, spec))
with _IN_FLIGHT_LOCK:
if job.key in _IN_FLIGHT:
# 上一轮还没跑完 —— 跳过这一轮,别叠加(那等于把速率翻倍)
logger.info("自动化作业 {} 仍在执行,本轮跳过", job.key)
continue
_IN_FLIGHT.add(job.key)
dispatched.append(job.key)
if wait:
# 同步路径(测试 / 手动触发):**顺序跑、用调用方的 session**。
# 这样调用方跑完就能直接读到自己 session 里的最新状态。
# 生产路径(下面是 wait=False)才走线程池 + 独立 session。
outcomes_sync: list[dict[str, Any]] = []
for key in dispatched:
try:
job = db.query(AutomationJob).filter(AutomationJob.key == key).first()
spec = _REGISTRY.get(key)
if job is not None and spec is not None:
outcomes_sync.append(_run_one(db, job, spec))
except Exception: # noqa: BLE001 作业边界要兜住,不能拖垮调度
logger.exception("自动化作业 {} 执行异常", key)
finally:
_release(key)
# 到期但没注册实现的作业只留日志,不静默消失
for job in jobs:
if job.key not in _REGISTRY:
logger.warning("自动化作业 {} 没有注册实现,已跳过", job.key)
return outcomes_sync
futures = {}
executor = _get_executor()
for key in dispatched:
futures[executor.submit(_run_job_isolated, key)] = key
# 到期但没注册实现的作业(例如降级回滚后残留的行)只留日志,不静默消失。
for job in jobs:
if job.key not in _REGISTRY:
logger.warning("自动化作业 {} 没有注册实现,已跳过", job.key)
return outcomes
# 派发完就返回;在途守卫会在各自跑完后清掉标记。
for future, key in futures.items():
future.add_done_callback(lambda _f, k=key: _release(k))
return []
def _release(job_key: str) -> None:
"""清掉在途标记(无论成功失败都要清,否则这个作业以后再也不跑了)。"""
with _IN_FLIGHT_LOCK:
_IN_FLIGHT.discard(job_key)
def _run_one(db: Session, job: AutomationJob, spec: JobSpec) -> dict[str, Any]:
@@ -265,7 +360,8 @@ def _loop() -> None:
while not _stop_event.wait(TICK_SECONDS):
db = SessionLocal()
try:
run_due_jobs(db)
# wait=False:派发完就返回 —— tick 不再被慢作业拖住
run_due_jobs(db, wait=False)
except Exception: # noqa: BLE001 单轮异常不能让调度线程死掉
db.rollback()
logger.exception("自动化调度轮询异常")
@@ -297,6 +393,12 @@ def stop_scheduler() -> None:
if _thread is None:
return
_stop_event.set()
global _EXECUTOR
with _EXECUTOR_LOCK:
if _EXECUTOR is not None:
# 不 cancel_futures:让已经在跑的作业自然跑完,硬砍会让账号停在半路
_EXECUTOR.shutdown(wait=False)
_EXECUTOR = None
_thread.join(timeout=5)
_thread = None