fix(critical): 修复调度器静默失效、数据源缺失与前端类型错误
C1 定时任务静默失效(4 层缺陷):
- scheduler.py: croniter(expr, datetime.now) 未调用 now(),
导致 TypeError 被吞、next_run 恒为 None(最深一层)
- scheduler.py: _calc_next 非法 cron 静默吞异常 -> 改为构造期抛 ValueError
- scheduler.py: _run_loop 一次异常即永久停摆 -> 增加异常隔离
- scheduler.py: sleep(min(wait,60)) 使运行期 cron 变更最长 60s 才生效
-> 改为固定 SLEEP_TICK 轮询
- app.py / schedules.py: 3 处 register() 第 2 参误传 task 类型名
C3 EvalPage.tsx: 引用未导入的 EmptyState -> 改为已导入的 EmptyText
(tsc --noEmit 由 1 error 变为 0 error)
新增 tests/test_scheduler_registration.py: 9 项行为回归测试
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
"""C1 回归测试: 定时任务调度器「静默失效」缺陷。
|
||||
|
||||
原始缺陷(全量审查 C1):
|
||||
1. `Scheduler.register()` 的第 2 参是 cron 表达式,但 app.py:52 /
|
||||
schedules.py:95,119 三处调用传的都是任务类型字符串("events" 等)。
|
||||
`croniter("events")` 抛异常被 `_calc_next` 的 except 吞掉 →
|
||||
next_run 永远为 None → 所有定时任务从不触发且无任何报错。
|
||||
2. 运行期通过 API 新建的任务只进了 _tasks 字典,从未
|
||||
asyncio.create_task 启动 _run_loop(只有 Scheduler.start() 会建循环)。
|
||||
|
||||
这些用例不依赖数据库,直接对调度器类做行为断言。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.scheduler import ScheduledTask, Scheduler
|
||||
|
||||
|
||||
class TestCronValidation:
|
||||
"""非法 cron 必须显式报错,不能静默变成永不触发。"""
|
||||
|
||||
def test_nonevent_cron_raises(self):
|
||||
"""把任务类型字符串当 cron 传(原缺陷) → 构造时立即抛 ValueError。"""
|
||||
async def _noop() -> None: ...
|
||||
with pytest.raises(ValueError, match="cron"):
|
||||
ScheduledTask("daily-events", "events", _noop)
|
||||
|
||||
def test_valid_cron_accepted(self):
|
||||
"""合法 cron 正常构造,且 next_run 被计算出来(非 None)。"""
|
||||
async def _noop() -> None: ...
|
||||
task = ScheduledTask("daily-events", "0 8 * * *", _noop)
|
||||
assert task.next_run is not None
|
||||
|
||||
def test_next_run_is_in_future(self):
|
||||
"""next_run 必须落在未来,否则 _run_loop 会立刻误触发。"""
|
||||
from datetime import datetime
|
||||
async def _noop() -> None: ...
|
||||
task = ScheduledTask("t", "*/30 * * * *", _noop)
|
||||
assert task.next_run > datetime.now()
|
||||
|
||||
def test_croniter_receives_called_datetime(self):
|
||||
"""回归: `croniter(expr, datetime.now)`(未调用)会抛
|
||||
TypeError: 'builtin_function_or_method' object cannot be interpreted
|
||||
as an integer —— 这是比「传错参数」更深一层的静默失效根源。
|
||||
断言 next_run 是真实 datetime,而非 None。"""
|
||||
from datetime import datetime
|
||||
async def _noop() -> None: ...
|
||||
task = ScheduledTask("t", "0 8 * * *", _noop)
|
||||
assert isinstance(task.next_run, datetime), (
|
||||
f"next_run 应为 datetime,实际 {task.next_run!r} —— "
|
||||
"croniter 的 start_time 未正确传入"
|
||||
)
|
||||
|
||||
|
||||
class TestRunLoopActuallyFires:
|
||||
"""注册后任务必须真的在到期时被执行(端到端行为,不 mock 内部)。"""
|
||||
|
||||
async def test_register_then_start_runs_task(self):
|
||||
"""cron 到点后任务函数被调用一次。用 1 秒粒度的 * * * * * 加速验证。"""
|
||||
calls: list[str] = []
|
||||
|
||||
async def _job() -> None:
|
||||
calls.append("ran")
|
||||
|
||||
sched = Scheduler()
|
||||
sched.register("t1", "* * * * *", _job, enabled=True)
|
||||
await sched.start()
|
||||
try:
|
||||
# _run_loop 用 min(wait, 60) 分段睡;下一分钟边界最多 60 秒。
|
||||
# 为让测试可跑,直接把 next_run 拨到过去,触发一次执行。
|
||||
task = sched.get("t1")
|
||||
from datetime import datetime, timedelta
|
||||
task.next_run = datetime.now() - timedelta(seconds=1)
|
||||
for _ in range(40):
|
||||
if calls:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
finally:
|
||||
await sched.stop()
|
||||
assert calls == ["ran"], "注册并 start 后,到期任务未被执行"
|
||||
|
||||
async def test_register_after_start_also_runs(self):
|
||||
"""运行期(已 start 之后)新 register 的任务也必须被启动(原缺陷 2)。"""
|
||||
calls: list[str] = []
|
||||
|
||||
async def _job() -> None:
|
||||
calls.append("late")
|
||||
|
||||
sched = Scheduler()
|
||||
await sched.start()
|
||||
try:
|
||||
sched.register("late-task", "* * * * *", _job, enabled=True)
|
||||
from datetime import datetime, timedelta
|
||||
sched.get("late-task").next_run = datetime.now() - timedelta(seconds=1)
|
||||
for _ in range(40):
|
||||
if calls:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
finally:
|
||||
await sched.stop()
|
||||
assert calls == ["late"], "start 之后注册的任务未被启动循环"
|
||||
|
||||
async def test_disabled_task_does_not_run(self):
|
||||
"""enabled=False 的任务不执行。"""
|
||||
calls: list[str] = []
|
||||
|
||||
async def _job() -> None:
|
||||
calls.append("nope")
|
||||
|
||||
sched = Scheduler()
|
||||
sched.register("off", "* * * * *", _job, enabled=False)
|
||||
await sched.start()
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
sched.get("off").next_run = datetime.now() - timedelta(seconds=1)
|
||||
await asyncio.sleep(0.3)
|
||||
finally:
|
||||
await sched.stop()
|
||||
assert calls == []
|
||||
|
||||
|
||||
class TestTaskFailureIsolation:
|
||||
"""单个任务抛异常不能杀掉循环(否则一次失败永久停摆)。"""
|
||||
|
||||
async def test_exception_does_not_kill_loop(self):
|
||||
"""任务抛异常后,循环仍存活并能在下一次到期时继续执行。"""
|
||||
runs: list[int] = []
|
||||
|
||||
async def _boom() -> None:
|
||||
runs.append(1)
|
||||
if len(runs) == 1:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
sched = Scheduler()
|
||||
sched.register("flaky", "* * * * *", _boom, enabled=True)
|
||||
await sched.start()
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
task = sched.get("flaky")
|
||||
for _ in range(40):
|
||||
if len(runs) >= 2:
|
||||
break
|
||||
task.next_run = datetime.now() - timedelta(seconds=1)
|
||||
await asyncio.sleep(0.05)
|
||||
assert len(runs) >= 2, "任务首次抛异常后循环未继续"
|
||||
assert task._task is not None and not task._task.done(), (
|
||||
"循环在任务抛异常后已终止"
|
||||
)
|
||||
finally:
|
||||
await sched.stop()
|
||||
|
||||
async def test_next_run_change_takes_effect_promptly(self):
|
||||
"""运行期把 next_run 改到过去,循环须在 SLEEP_TICK 内感知并触发。
|
||||
|
||||
回归: 原实现 sleep(min(wait_seconds, 60)),当 wait_seconds<60 时
|
||||
会一次性睡满 wait_seconds,导致运行期通过 API 更新 cron 后
|
||||
最长 60s 不生效。
|
||||
"""
|
||||
calls: list[str] = []
|
||||
|
||||
async def _job() -> None:
|
||||
calls.append("ran")
|
||||
|
||||
import src.core.scheduler as sched_mod
|
||||
old_tick = sched_mod.ScheduledTask.SLEEP_TICK
|
||||
sched_mod.ScheduledTask.SLEEP_TICK = 0.05
|
||||
try:
|
||||
sched = Scheduler()
|
||||
sched.register("tick", "* * * * *", _job, enabled=True)
|
||||
await sched.start()
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
# 先等循环进入 sleep(wait 接近 60s 的最坏情况)
|
||||
await asyncio.sleep(0.15)
|
||||
sched.get("tick").next_run = datetime.now() - timedelta(seconds=1)
|
||||
# 短 tick 应在 ~0.15s 内感知;给 1s 容差
|
||||
for _ in range(40):
|
||||
if calls:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
finally:
|
||||
await sched.stop()
|
||||
finally:
|
||||
sched_mod.ScheduledTask.SLEEP_TICK = old_tick
|
||||
assert calls == ["ran"], (
|
||||
"next_run 变更后循环未在 tick 内响应(疑似一次性睡满 wait_seconds)"
|
||||
)
|
||||
Reference in New Issue
Block a user