Files
live-hub-py/tests/test_automation_ck_health.py
T
yml2213 55f899c615 feat(automation): 拆开「多久跑一次」与「同一账号多久重检」—— 调度频率终于可配
用户反馈:「单账号重检间隔(小时) 很别扭,直接设置多久跑一次、一次跑多少个更符合直觉」。

**对,而且比"别扭"更严重:调度频率以前根本改不了。**

## 问题:两个概念挤在一个字段里

`_run_one` 里原本写着:

    job.next_run_at = finished + timedelta(seconds=JOB_INTERVAL_SECONDS)   # 硬编码 300 秒

也就是不管界面上填什么,下一轮永远是「跑完 + 5 分钟」。
而界面那个「单账号重检间隔(小时)」对应的是 `interval_seconds` ——
它只被作业**自己的选行逻辑**使用("这行多久该重检"),**完全不参与调度**。

于是出现最坏的情况:**看起来能配调度,其实配的是一个不相干的参数**。
上一轮我还拿它算过吞吐("10 分钟一轮"),算错了 —— 已在调度器注释里纠正。

## 拆法

| 概念 | 字段 | 界面 |
| --- | --- | --- |
| **多久跑一次** | `run_interval_seconds`(迁移 0051,默认 300)| 「多久跑一次(分钟)」|
| 同一账号多久重检 | `interval_seconds`(不变)| 「同一账号多久重检(小时)」|

- `next_run_at = finished + run_interval_seconds`(有下限 30 秒,避免忙轮询)
- 接口:`GET` 返回新字段、`PATCH` 可改
- 前端:两个输入框分开,并把原来那段绕口的 tooltip
  ("它不决定调度频率 —— 调度周期见页面顶部说明")改掉 ——
  那句话本身就是这个设计缺陷的自白

## 默认 300 秒

与改动前**实际行为一致** —— 不会因为这次迁移让已有作业突然变快或变慢。
想调快就自己填(比如「账号资料定时补全」填 5 分钟 + 每轮 400 条 ≈ 3 小时补完)。

## ⚠️ 顺带说明一个你截图里可能看到的现象

截图里「账号资料定时补全」显示的是 **0.5 小时 / 30 条** —— 那是我**最初的默认值**。
因为 `ensure_jobs` **只创建不更新**,我后面改成 10 分钟/400 **不会作用到已存在的行**。
这是刻意的(否则会覆盖运维的手工调整),但意味着**默认值改了要手动在界面上改**,
或者删掉那行让 `ensure_jobs` 重建。现在有了「多久跑一次」这个框,直接填就行。

## 测试(+3)

- 下一轮按**运行间隔**排,不是按「同一行重检间隔」(两个值刻意设成不同来钉住)
- 不填时默认 300 秒(保持旧行为)
- 过小的间隔被夹到下限

## 验证

TZ=Asia/Shanghai **639 passed / 6 skipped**;ruff 全绿;前端 build 通过、lint 0 errors。
2026-10-04 21:30:13 +08:00

997 lines
36 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.
"""P1a 回归:CK 自动巡检。
两块约束必须一起守住:
1. **调度器行为**:只跑到点的、打开开关的作业;作业抛异常只记 failed,不能
把调度线程带崩;跑完排下一轮时间;已有配置不能被重启重置。
2. **本作业绝不改 CK**:P1a 的定位是「只检测、只标记」,所以巡检后
**账号的** ``cookie`` / ``status`` / ``relogin_*`` 以及登录尝试历史的
``status`` / ``message`` / ``attempt_*`` 必须一字未动 ——
线上全靠这条保证「自动化上线了但人工流程照旧」,回归时一旦有人顺手写了
``cookie`` 或 ``status``,这里要立刻红。
> CK 与健康/重登状态自 2026-10-04 起是 **``accounts`` 的列**
> (见 ``docs/设计-账号全生命周期.md`` §3.3);``login_tasks`` 退化为纯登录
> 尝试历史,它的 ``cookie`` / ``ck_check_*`` / ``relogin_*`` 旧列留作回退、
> 新代码不再读写,测试也不许再往上面写值。
"""
import threading
import time
from datetime import UTC, datetime, timedelta
import pytest
from fastapi import HTTPException
from tests.support.db import get_test_engine, make_test_session
from web.backend.deps import require_permission
from web.backend.models import Account, AutomationJob, AutomationRun, LoginTask, User
from web.backend.services.automation import (
JobSpec,
register_default_jobs,
register_job,
reset_registry,
run_due_jobs,
run_job_now,
scheduler,
)
from web.backend.services.automation.jobs import ck_health
class _Base:
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.admin = User(
username="admin", password_hash="h", role="super_admin", is_active=True
)
self.support = User(
username="kf", password_hash="h", role="support", is_active=True
)
self.session.add_all([self.admin, self.support])
self.session.commit()
# 注册表是模块级的,不重置会被上一个用例注册的探针作业污染。
reset_registry()
register_default_jobs()
def teardown_method(self):
self.session.close()
def make_account(
self,
name="acc",
*,
cookie="acf_uid=1",
status="active",
auto_managed=True,
assigned_to=None,
ck_check_status="",
ck_check_result=None,
ck_checked_at=None,
relogin_attempts=0,
relogin_next_at=None,
relogin_last_error="",
relogin_failure_kind="",
):
"""建一个账号。**CK 与健康/重登状态都是账号自己的列。**
默认 ``cookie="acf_uid=1"``(与迁移前 ``make_task`` 的默认一致):
巡检的候选口径是「账号有 CK」,所以不显式给 cookie
的账号本来就不该进队列 —— 要造这种账号就传 ``cookie=""``。
"""
account = Account(
username=name,
password="p",
email=f"{name}@example.com",
email_password="ep",
assigned_to=assigned_to,
auto_managed=auto_managed,
cookie=cookie,
status=status,
ck_check_status=ck_check_status,
ck_check_result=ck_check_result,
ck_checked_at=ck_checked_at,
relogin_attempts=relogin_attempts,
relogin_next_at=relogin_next_at,
relogin_last_error=relogin_last_error,
relogin_failure_kind=relogin_failure_kind,
)
self.session.add(account)
self.session.commit()
return account
def make_attempt(
self,
account,
*,
status="success",
message="",
attempt_status="",
attempt_message="",
attempt_batch_id="",
attempt_at=None,
):
"""建一条登录尝试历史(``LoginTask`` 已退化为纯尝试流水)。
只写它保留下来的字段:``cookie`` / ``ck_check_*`` / ``relogin_*``
下一次发版就要 drop,测试再往上写值只会掩盖解耦没做干净。
"""
attempt = LoginTask(
batch_id="b1",
account_id=account.id,
created_by=self.admin.id,
status=status,
message=message,
attempt_status=attempt_status,
attempt_message=attempt_message,
attempt_batch_id=attempt_batch_id,
attempt_at=attempt_at,
)
self.session.add(attempt)
self.session.commit()
return attempt
class TestCkHealthSelection(_Base):
def _selected_ids(self, job):
"""返回被选中**账号**的 id(解耦后按账号选行,不再按登录记录选)。"""
return [
a.id
for a in ck_health._select_accounts(self.session, job, datetime.now(UTC))
]
def test_picks_only_accounts_that_still_hold_cookie(self):
job = self._job()
with_cookie = self.make_account("a")
self.make_account("b", cookie="")
assert self._selected_ids(job) == [with_cookie.id]
def test_skips_accounts_with_inflight_attempt(self):
job = self._job()
idle = self.make_account("a")
running = self.make_account("b")
self.make_attempt(running, attempt_status="running")
pending = self.make_account("c")
self.make_attempt(pending, attempt_status="pending")
assert self._selected_ids(job) == [idle.id]
def test_skips_accounts_opted_out_of_automation(self):
job = self._job()
managed = self.make_account("a")
self.make_account("b", auto_managed=False)
assert self._selected_ids(job) == [managed.id]
def test_account_without_login_history_is_selectable(self):
"""回归:解耦前选行是 ``LoginTask JOIN Account``,从没登录过的账号永远进不了队列。
CK 现在挂在账号上,选行只按账号判定 —— 没有尝试历史的账号也必须被巡检。
"""
job = self._job()
account = self.make_account("only-account")
assert self.session.query(LoginTask).count() == 0
assert self._selected_ids(job) == [account.id]
def test_respects_interval_and_prefers_stalest(self):
job = self._job()
fresh = self.make_account("fresh", ck_checked_at=datetime.now(UTC))
stale = self.make_account(
"stale", ck_checked_at=datetime.now(UTC) - timedelta(hours=30)
)
never = self.make_account("never", ck_checked_at=None)
ids = self._selected_ids(job)
assert fresh.id not in ids
# 从没检测过的排最前,其次是久未检测的
assert ids[0] == never.id
assert stale.id in ids
def test_generated_sql_is_mysql_compatible(self):
"""回归:选行 SQL 不能出现 MySQL 不认识的 NULLS FIRST。
生产是 MySQL、本地测试是 SQLite(SQLite 支持 NULLS FIRST,所以行为测试
发现不了),这里按 MySQL 方言编译一次断言。
"""
from sqlalchemy.dialects import mysql
query = ck_health.build_select_query(
self.session, self._job(), datetime.now(UTC)
)
sql = str(query.statement.compile(dialect=mysql.dialect())).upper()
assert "NULLS FIRST" not in sql and "NULLS LAST" not in sql
# 「没检测过的优先」要用可移植写法表达
assert "IS NULL DESC" in sql
def test_batch_size_caps_the_round(self):
job = self._job()
job.batch_size = 2
for index in range(5):
self.make_account(f"a{index}")
assert len(self._selected_ids(job)) == 2
def _job(self):
return AutomationJob(
key=ck_health.JOB_KEY, interval_seconds=6 * 3600, batch_size=200
)
class TestCkHealthNeverTouchesCookie(_Base):
def test_patrol_only_writes_check_fields(self):
job = self._job()
# 账号自带 CK、生命周期状态与重登进度;巡检只许动 ck_check_* 三件套。
account = self.make_account(
"acc",
cookie="acf_uid=1",
status="active",
relogin_attempts=2,
relogin_next_at=datetime.now(UTC) + timedelta(hours=1),
relogin_last_error="上次登录失败",
relogin_failure_kind="network",
)
attempt = self.make_attempt(
account,
status="success",
message="历史登录结果",
attempt_status="failed",
attempt_message="历史失败",
attempt_batch_id="b0",
)
before = (
account.cookie,
account.status,
account.relogin_attempts,
account.relogin_next_at,
account.relogin_last_error,
account.relogin_failure_kind,
)
before_attempt = (
attempt.status,
attempt.message,
attempt.attempt_status,
attempt.attempt_message,
attempt.attempt_batch_id,
attempt.attempt_at,
)
ck_health.check_douyu_cookie = _fake_check(valid=True)
try:
counters = ck_health.run_ck_health(self.session, job)
finally:
ck_health.check_douyu_cookie = _REAL_CHECK
self.session.refresh(account)
self.session.refresh(attempt)
assert counters["scanned"] == 1 and counters["valid"] == 1
assert account.ck_check_status == "valid"
assert account.ck_checked_at is not None
assert account.ck_check_result["nickname"] == "测试"
# 核心断言:账号的 CK 本体、生命周期状态、重登进度一字未动
assert (
account.cookie,
account.status,
account.relogin_attempts,
account.relogin_next_at,
account.relogin_last_error,
account.relogin_failure_kind,
) == before
# 登录尝试历史同样不是巡检该碰的东西
assert (
attempt.status,
attempt.message,
attempt.attempt_status,
attempt.attempt_message,
attempt.attempt_batch_id,
attempt.attempt_at,
) == before_attempt
def test_invalid_result_marks_invalid_but_keeps_cookie(self):
job = self._job()
account = self.make_account("acc", cookie="acf_uid=1")
# 必须是鉴权类失败(斗鱼说未登录)才会被标成 invalid
ck_health.check_douyu_cookie = _fake_check(
valid=False,
message="鱼丸接口: 账户登陆异常,请重新登陆;等级接口: 用户未登录",
)
try:
counters = ck_health.run_ck_health(self.session, job)
finally:
ck_health.check_douyu_cookie = _REAL_CHECK
self.session.refresh(account)
assert counters["invalid"] == 1
assert account.ck_check_status == "invalid"
assert account.cookie == "acf_uid=1"
def test_detection_exception_does_not_abort_round(self):
"""单个账号检测抛异常不影响整轮,但也不能把它标成失效。"""
job = self._job()
first = self.make_account("a", ck_check_status="valid")
second = self.make_account("b", ck_check_status="valid")
ck_health.check_douyu_cookie = _fake_check(raises=True)
try:
counters = ck_health.run_ck_health(self.session, job)
finally:
ck_health.check_douyu_cookie = _REAL_CHECK
assert counters["scanned"] == 2
# 查不到不等于失效:两个账号都保留原结论,只记录这次异常
assert counters["invalid"] == 0
assert counters["skipped"] == 2
for account in (first, second):
self.session.refresh(account)
assert account.ck_check_status == "valid"
assert account.ck_check_result["verdict"] == "error"
def _job(self):
return AutomationJob(
key=ck_health.JOB_KEY, interval_seconds=6 * 3600, batch_size=200
)
class TestScheduler(_Base):
def _register_probe(self, calls):
def run(db, job):
calls.append(job.key)
return {"scanned": 3, "valid": 2, "invalid": 1, "message": "ok"}
register_job(
JobSpec(
key="probe",
title="探针",
interval_seconds=3600,
batch_size=1,
concurrency=1,
run=run,
)
)
def test_only_enabled_jobs_run(self):
from web.backend.services.automation import ensure_jobs
calls: list[str] = []
self._register_probe(calls)
ensure_jobs(self.session)
self.session.query(AutomationJob).filter(AutomationJob.key == "probe").update(
{"enabled": True}
)
self.session.commit()
run_due_jobs(self.session)
assert calls == ["probe"]
calls.clear()
run_due_jobs(self.session)
# 上一轮已排了 next_run_at,未到点不再跑
assert calls == []
def test_job_failure_is_recorded_not_raised(self):
from web.backend.services.automation import ensure_jobs
def boom(db, job):
raise RuntimeError("接口挂了")
register_job(
JobSpec(
key="boom",
title="会炸的作业",
interval_seconds=3600,
batch_size=1,
concurrency=1,
run=boom,
)
)
ensure_jobs(self.session)
self.session.query(AutomationJob).filter(AutomationJob.key == "boom").update(
{"enabled": True}
)
self.session.commit()
outcomes = run_due_jobs(self.session)
assert outcomes[0]["status"] == "failed"
run = self.session.query(AutomationRun).one()
assert run.status == "failed" and "接口挂了" in run.message
def test_next_run_follows_tick_and_counters_persist(self):
from web.backend.services.automation import ensure_jobs
calls: list[str] = []
self._register_probe(calls)
ensure_jobs(self.session)
self.session.query(AutomationJob).filter(AutomationJob.key == "probe").update(
{"enabled": True}
)
self.session.commit()
run_due_jobs(self.session)
job = (
self.session.query(AutomationJob).filter(AutomationJob.key == "probe").one()
)
assert job.last_status == "success"
# 下一轮在一个作业周期之后,与 interval_seconds(每行多久重检)无关
from web.backend.services.automation.scheduler import JOB_INTERVAL_SECONDS
assert _aware(job.next_run_at) - _aware(job.last_run_at) == timedelta(
seconds=JOB_INTERVAL_SECONDS
)
run = self.session.query(AutomationRun).one()
assert (run.scanned, run.valid, run.invalid) == (3, 2, 1)
def test_ensure_jobs_keeps_operator_settings(self):
from web.backend.services.automation import ensure_jobs
ensure_jobs(self.session)
job = (
self.session.query(AutomationJob)
.filter(AutomationJob.key == ck_health.JOB_KEY)
.one()
)
job.enabled = True
job.batch_size = 7
self.session.commit()
ensure_jobs(self.session) # 模拟重启
self.session.refresh(job)
assert job.enabled is True and job.batch_size == 7
def test_run_job_now_ignores_switch(self):
from web.backend.services.automation import ensure_jobs
ensure_jobs(self.session)
outcome = run_job_now(self.session, ck_health.JOB_KEY)
assert outcome["job_key"] == ck_health.JOB_KEY
assert self.session.query(AutomationRun).count() == 1
def test_run_job_now_unknown_key(self):
with pytest.raises(KeyError):
run_job_now(self.session, "nope")
class TestAutomationPermissions(_Base):
def test_view_allows_operation_denies_support(self):
from web.backend.permissions import ROLE_PERMISSIONS
assert "automation:view" in ROLE_PERMISSIONS["operation"]
assert "automation:view" not in ROLE_PERMISSIONS["support"]
assert "automation:manage" in ROLE_PERMISSIONS["super_admin"]
assert "automation:manage" not in ROLE_PERMISSIONS["operation"]
def test_manage_dependency_rejects_operator(self):
operator = User(
username="op", password_hash="h", role="operation", is_active=True
)
checker = require_permission("automation:manage")
with pytest.raises(HTTPException) as exc:
checker(current_user=operator)
assert exc.value.status_code == 403
def test_view_dependency_accepts_operator(self):
operator = User(
username="op", password_hash="h", role="operation", is_active=True
)
checker = require_permission("automation:view")
assert checker(current_user=operator).id == operator.id
def _aware(value):
"""库里读出的是 naive UTC,比较前补时区。"""
return value if value.tzinfo else value.replace(tzinfo=UTC)
_REAL_CHECK = ck_health.check_douyu_cookie
def _fake_check(*, valid=True, raises=False, message=None):
def fake(cookie: str) -> dict:
if raises:
raise RuntimeError("网络异常")
return {
"valid": valid,
"message": message
if message is not None
else ("ok" if valid else "Cookie 已失效"),
"fish_ball": 1,
"nickname": "测试",
"level": 30,
"checked_at": datetime.now(UTC),
}
return fake
class TestAutomationApi(_Base):
"""接口层:序列化字段、开关落库、手动触发不受开关限制。"""
def _api(self):
from web.backend.routers import automation as api
return api
def test_list_jobs_exposes_scheduler_and_job_state(self):
api = self._api()
payload = api.list_jobs(db=self.session, current=self.admin)
# 心跳(轮询间隔)与作业周期是两个概念,接口都要暴露出来:
# 心跳密、只做「有没有到点」的检查;作业周期决定同一个作业隔多久跑下一轮。
assert (
payload["scheduler"]["tick_seconds"]
< payload["scheduler"]["job_interval_seconds"]
)
assert payload["scheduler"]["job_interval_seconds"] == 300
assert payload["scheduler"]["running"] is False
keys = [item["key"] for item in payload["jobs"]]
assert ck_health.JOB_KEY in keys
job = next(item for item in payload["jobs"] if item["key"] == ck_health.JOB_KEY)
assert job["enabled"] is False
assert job["interval_seconds"] == 6 * 3600
assert job["batch_size"] == 200
assert job["latest_run"] is None
def test_update_job_persists_and_schedules_next_run(self):
api = self._api()
api.update_job(
ck_health.JOB_KEY,
api.JobUpdate(
enabled=True, interval_seconds=3600, batch_size=5, concurrency=2
),
db=self.session,
current=self.admin,
)
job = (
self.session.query(AutomationJob)
.filter(AutomationJob.key == ck_health.JOB_KEY)
.one()
)
assert job.enabled is True
assert job.interval_seconds == 3600
assert job.batch_size == 5
assert job.next_run_at is not None # 打开开关就排了一轮
# 审计留痕
from web.backend.models import AuditLog
entry = (
self.session.query(AuditLog)
.filter(AuditLog.action == "automation:update")
.one()
)
assert entry.target == ck_health.JOB_KEY
def test_run_endpoint_records_run_and_audit(self):
api = self._api()
self.make_account("a")
ck_health.check_douyu_cookie = _fake_check(valid=True)
try:
outcome = api.run_job(
ck_health.JOB_KEY, db=self.session, current=self.admin
)
finally:
ck_health.check_douyu_cookie = _REAL_CHECK
assert outcome["status"] == "success"
assert outcome["scanned"] == 1
assert self.session.query(AutomationRun).count() == 1
from web.backend.models import AuditLog
assert (
self.session.query(AuditLog)
.filter(AuditLog.action == "automation:run")
.count()
== 1
)
def test_run_endpoint_unknown_job_returns_404(self):
api = self._api()
with pytest.raises(HTTPException) as exc:
api.run_job("nope", db=self.session, current=self.admin)
assert exc.value.status_code == 404
def test_runs_endpoint_filters_by_job_key(self):
api = self._api()
self.session.add(
AutomationRun(job_key=ck_health.JOB_KEY, status="success", scanned=1)
)
self.session.add(AutomationRun(job_key="other", status="failed"))
self.session.commit()
payload = api.list_runs(
job_key=ck_health.JOB_KEY, limit=50, db=self.session, current=self.admin
)
assert len(payload["items"]) == 1
assert payload["items"][0]["job_key"] == ck_health.JOB_KEY
class TestFailureClassification(_Base):
"""「查不到」不等于「失效」:非鉴权类失败不能覆盖原来的结论。
斗鱼接口抖动/被风控时,如果不加区分地写 invalid,一小时就能把上千个好账号
标成失效,客服会误判成 CK 大面积挂掉。这里盯三件事:分类正确、error 不改
结论、失败占比过高时自动暂停作业。
"""
def test_auth_failure_message_counts_as_invalid(self):
assert (
ck_health.classify_result(
{
"valid": False,
"message": "鱼丸接口: 账户登陆异常,请重新登陆;等级接口: 用户未登录",
}
)
== "invalid"
)
def test_network_failure_counts_as_error_not_invalid(self):
assert (
ck_health.classify_result(
{
"valid": False,
"message": "鱼丸接口: 请求失败: timeout;等级接口: 请求失败: timeout",
}
)
== "error"
)
assert (
ck_health.classify_result({"valid": False, "message": "响应异常"})
== "error"
)
def test_valid_still_valid(self):
assert ck_health.classify_result({"valid": True, "message": "有效"}) == "valid"
def test_error_keeps_previous_verdict_but_marks_checked(self):
job = self._job()
account = self.make_account("acc", ck_check_status="valid")
ck_health.check_douyu_cookie = _fake_check(
valid=False, message="请求失败: timeout"
)
try:
counters = ck_health.run_ck_health(self.session, job)
finally:
ck_health.check_douyu_cookie = _REAL_CHECK
self.session.refresh(account)
assert counters["valid"] == 0 and counters["invalid"] == 0
assert counters["skipped"] == 1 # error 计入 skipped
assert account.ck_check_status == "valid" # 原结论保留
assert account.ck_check_result["verdict"] == "error" # 但记录了这次查不到
assert account.ck_checked_at is not None
def test_mass_network_failure_pauses_the_job(self):
job = self._job()
job.enabled = True
job.batch_size = 40
for index in range(40):
self.make_account(f"a{index}")
ck_health.check_douyu_cookie = _fake_check(
valid=False, message="请求失败: Connection refused"
)
try:
counters = ck_health.run_ck_health(self.session, job)
finally:
ck_health.check_douyu_cookie = _REAL_CHECK
assert counters["skipped"] == 40
assert counters["detail"]["paused"] is True
assert job.enabled is False # 自动暂停,不再继续污染
assert "已自动暂停" in counters["message"]
def test_normal_invalid_does_not_pause(self):
"""真的都是死号时不能暂停 —— 首轮 100% invalid 是合法且预期的情况。"""
job = self._job()
job.enabled = True
job.batch_size = 40
for index in range(40):
self.make_account(f"a{index}")
ck_health.check_douyu_cookie = _fake_check(
valid=False,
message="鱼丸接口: 账户登陆异常,请重新登陆;等级接口: 用户未登录",
)
try:
counters = ck_health.run_ck_health(self.session, job)
finally:
ck_health.check_douyu_cookie = _REAL_CHECK
assert counters["invalid"] == 40
assert counters["skipped"] == 0
assert job.enabled is True
assert counters["detail"]["paused"] is False
def _job(self):
return AutomationJob(
key=ck_health.JOB_KEY,
interval_seconds=6 * 3600,
batch_size=200,
concurrency=3,
)
class TestOrphanRunCleanup(_Base):
"""回归:进程重启后,卡在 running 的运行记录必须被收尾。
作业在调度线程里同步跑,进程一重启那轮就没机会写回结果,记录会永远停在
running,页面上一直显示「执行中」。登录任务早有孤儿清理,运行记录原先漏了
—— 线上部署时打断了一轮,run 14 就是这么卡住的。
"""
def test_running_runs_are_closed_on_startup(self):
from web.backend.services.automation.scheduler import cleanup_orphan_runs
stale = AutomationRun(
job_key="ck_relogin", started_at=datetime.now(UTC), status="running"
)
done = AutomationRun(
job_key="ck_health",
started_at=datetime.now(UTC),
finished_at=datetime.now(UTC),
status="success",
)
self.session.add_all([stale, done])
self.session.commit()
cleaned = cleanup_orphan_runs(self.session)
self.session.refresh(stale)
self.session.refresh(done)
assert cleaned == 1
assert stale.status == "interrupted"
assert stale.finished_at is not None
assert stale.message == "服务重启中断"
assert done.status == "success" # 已完成的不受影响
class TestHeartbeatDecoupledFromInterval(_Base):
"""回归:心跳频率不能直接当作业周期用。
曾经 TICK_SECONDS 既是心跳又是作业周期(next_run_at = 结束 + 300s,心跳也是
300s)。两者同频会系统性跳轮:作业 19 秒跑完 → next 在 5 分钟后,而下一次
心跳正好在 5 分钟整,常常差几十秒没到点,于是整轮空转 —— 实际变成每 10 分钟
才跑一次。线上表现就是「检测只要 19 秒,却总是排队等很久」。
"""
def test_heartbeat_is_much_shorter_than_job_interval(self):
from web.backend.services.automation.scheduler import (
JOB_INTERVAL_SECONDS,
TICK_SECONDS,
)
assert TICK_SECONDS < JOB_INTERVAL_SECONDS, (
"心跳必须比作业周期密,否则会出现「差几十秒没到点→整轮空转」的跳轮"
)
# 至少密 4 倍:作业耗时波动时不会积压成整轮跳过
assert JOB_INTERVAL_SECONDS / TICK_SECONDS >= 4
def test_job_runs_again_once_its_own_interval_elapsed(self):
"""心跳多跳几次后,到点的作业必须被跑到。"""
job = self._register_probe()
run_due_jobs(self.session)
first = self.session.query(AutomationRun).count()
# 周期未到:无论心跳跳多少次都不跑
for _ in range(3):
run_due_jobs(self.session)
assert self.session.query(AutomationRun).count() == first
# 把 next_run_at 拨到过去(等价于「周期已到」),下一次心跳就该跑
job.next_run_at = datetime.now(UTC) - timedelta(seconds=1)
self.session.commit()
run_due_jobs(self.session)
assert self.session.query(AutomationRun).count() == first + 1
def _register_probe(self):
from web.backend.services.automation.scheduler import (
JobSpec,
register_job,
reset_registry,
)
reset_registry()
register_job(
JobSpec(
key="probe",
title="探针",
interval_seconds=6 * 3600,
batch_size=10,
concurrency=1,
run=lambda db, job: {"scanned": 1, "valid": 1, "invalid": 0},
)
)
from web.backend.services.automation import ensure_jobs
ensure_jobs(self.session)
job = (
self.session.query(AutomationJob).filter(AutomationJob.key == "probe").one()
)
job.enabled = True
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, "作业应该真的跑过一次"
class TestRunIntervalIsScheduling:
"""「多久跑一次」与「同一行多久重检」是**两个独立概念**。
用户反馈:界面上那个「单账号重检间隔(小时)」很别扭,直接设「多久跑一次、
一次跑多少个」更符合直觉 —— 对,因为**调度频率以前根本改不了**:
job.next_run_at = finished + timedelta(seconds=JOB_INTERVAL_SECONDS) # 硬编码 5 分钟
于是不管界面上填什么,下一轮永远是「跑完 + 5 分钟」。
"""
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, run_interval: int | None):
calls: list[int] = []
def probe(db, job):
calls.append(1)
return {"scanned": 1}
register_job(
JobSpec(
key="probe",
title="probe",
interval_seconds=6 * 3600, # 同一行重检间隔(不参与调度)
batch_size=10,
concurrency=1,
run=probe,
**({"run_interval_seconds": run_interval} if run_interval else {}),
)
)
self.session.add(
AutomationJob(
key="probe",
title="probe",
interval_seconds=6 * 3600,
run_interval_seconds=run_interval or 300,
batch_size=10,
concurrency=1,
enabled=True,
)
)
self.session.commit()
return calls
def _next_run_delay(self) -> float:
run_due_jobs(self.session)
job = (
self.session.query(AutomationJob).filter(AutomationJob.key == "probe").one()
)
assert job.last_run_at is not None and job.next_run_at is not None
return (job.next_run_at - job.last_run_at).total_seconds()
def test_next_run_follows_run_interval_not_the_row_interval(self):
"""下一轮按**运行间隔**排,不是按「同一行重检间隔」。"""
self._job(run_interval=1800)
delay = self._next_run_delay()
assert delay == 1800, f"应按运行间隔 1800 排,实际 {delay}"
assert delay != 6 * 3600, "不能再用「同一行重检间隔」当调度周期"
def test_default_run_interval_keeps_old_behaviour(self):
"""不填时默认 300 秒 —— 与改动前的硬编码行为一致(不会突然变快变慢)。"""
self._job(run_interval=None)
assert self._next_run_delay() == 300
def test_too_small_interval_is_clamped(self):
"""太小的间隔是忙轮询,没有意义 —— 有下限。"""
self._job(run_interval=1)
assert self._next_run_delay() >= scheduler.MIN_RUN_INTERVAL_SECONDS