Files
re-xxm/tests/test_scheduler.py
T
bot-ci 9969e65202 fix(scheduler): drop cross-task mutex, quiet window + retry
Incident: evening full snapshot could be recorded as ok with
already_completed/elapsed=0 after mutual skip with hourly sync, then
next_run jumped to the next day.

- Full-site snapshot never skips because another task is running
- 23:00-01:00 Asia/Shanghai: defer discovery / snapshot_recent /
  candidate_cleanup to after 01:00; fans never defers
- Skip records ok=false + skipped=true (not success / not completed)
- Skip/fail reschedule soon (~5m); daily success still next snapshot_at
- TaskControl return value drives next_run; system UI shows skip fields
2026-11-03 00:07:19 +08:00

167 lines
4.4 KiB
Python

# -*- coding: utf-8 -*-
"""内置调度器测试(离线): uv run python tests/test_scheduler.py"""
import os
import sys
import threading
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8")
from reaction_board.scheduler import ScheduledTask, Scheduler, TaskControl # noqa: E402
def test_run_when_due_and_reschedule():
clock = [1_000.0]
calls = []
task = ScheduledTask("sync", 60, lambda: calls.append(clock[0]))
scheduler = Scheduler([task], clock=lambda: clock[0])
assert scheduler.run_due() and len(calls) == 1
assert task.next_run == 1_060.0
clock[0] = 1_059.0
assert scheduler.run_due() == []
assert len(calls) == 1
clock[0] = 1_060.0
results = scheduler.run_due()
assert len(results) == 1
assert calls == [1_000.0, 1_060.0]
assert task.next_run == 1_120.0
def test_task_error_does_not_block_other_tasks():
clock = [100.0]
calls = []
def boom():
raise RuntimeError("api down")
scheduler = Scheduler(
[
ScheduledTask("bad", 10, boom),
ScheduledTask("good", 10, lambda: calls.append("ok")),
],
clock=lambda: clock[0],
)
results = scheduler.run_due()
assert [r.ok for r in results] == [False, True]
assert results[0].error == "api down"
assert calls == ["ok"]
# 失败任务仍然被重新排期
assert all(t.next_run == 110.0 for t in scheduler.tasks)
def test_run_forever_with_stop_event():
clock = [0.0]
calls = []
def tick():
calls.append(clock[0])
clock[0] += 10
stop = threading.Event()
scheduler = Scheduler(
[ScheduledTask("tick", 10, tick)],
clock=lambda: clock[0],
sleeper=lambda seconds: None,
)
def stop_soon():
while len(calls) < 3:
pass
stop.set()
waiter = threading.Thread(target=stop_soon)
waiter.start()
scheduler.run_forever(stop_event=stop, poll_seconds=0.001)
waiter.join(timeout=2)
assert len(calls) >= 3
def test_max_iterations_and_validation():
calls = []
tick_clock = [0.0]
def next_time():
tick_clock[0] += 1.0
return tick_clock[0]
scheduler = Scheduler(
[ScheduledTask("t", 1, lambda: calls.append(1))],
clock=next_time,
sleeper=lambda seconds: None,
)
scheduler.run_forever(max_iterations=2)
assert len(calls) == 2
for tasks, kwargs in [
([], {}),
([ScheduledTask("bad", 0, lambda: None)], {}),
([ScheduledTask("t", 1, lambda: None)], {"poll_seconds": 0}),
]:
try:
s = Scheduler(tasks, sleeper=lambda seconds: None)
s.run_forever(max_iterations=1, **kwargs)
except ValueError:
pass
else:
raise AssertionError(f"应当失败: {tasks}, {kwargs}")
def test_next_run_fn_controls_reschedule():
clock = [1_000.0]
calls = []
task = ScheduledTask(
"daily", 86400, lambda: calls.append(clock[0]),
next_run_fn=lambda started: started + 123,
)
scheduler = Scheduler([task], clock=lambda: clock[0])
scheduler.run_due()
assert calls == [1_000.0]
assert task.next_run == 1_123.0
clock[0] = 1_122.0
assert scheduler.run_due() == []
clock[0] = 1_123.0
scheduler.run_due()
assert calls == [1_000.0, 1_123.0]
def test_task_control_overrides_ok_and_next_run():
clock = [1_000.0]
task = ScheduledTask(
"defer",
60,
lambda: TaskControl(ok=False, next_run=1_500.0, error="quiet"),
)
scheduler = Scheduler([task], clock=lambda: clock[0])
results = scheduler.run_due()
assert len(results) == 1
assert results[0].ok is False
assert results[0].error == "quiet"
assert task.next_run == 1_500.0
# 未到 next_run 不重跑
clock[0] = 1_499.0
assert scheduler.run_due() == []
clock[0] = 1_500.0
task.fn = lambda: TaskControl(ok=True, next_run=2_000.0)
results = scheduler.run_due()
assert results[0].ok is True
assert task.next_run == 2_000.0
def main():
test_run_when_due_and_reschedule()
test_next_run_fn_controls_reschedule()
test_task_control_overrides_ok_and_next_run()
test_task_error_does_not_block_other_tasks()
test_run_forever_with_stop_event()
test_max_iterations_and_validation()
print("OK: 内置调度器测试全部通过")
if __name__ == "__main__":
main()