Files
re-xxm/tests/test_scheduler.py
T
bot-ci 1697573d95 fix(scheduler): address !7 REQUEST_CHANGES
- seed: check completed_key before no_source so same-day last_completed
  is already_completed (not 10m retry)
- scheduler: resolve TaskControl ok/error before logging; ok=False logs
  task_fail, not task_ok
- candidate_cleanup: catch failures, record task, retry ~5m like discover
2026-11-03 01:08:34 +08:00

244 lines
7.0 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.
# -*- 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
class _CaptureLogger:
def __init__(self):
self.events = []
def info(self, event, **fields):
self.events.append(("info", event, fields))
def warning(self, event, **fields):
self.events.append(("warning", event, fields))
def error(self, event, **fields):
self.events.append(("error", event, fields))
def test_task_control_ok_false_logs_fail_not_ok():
"""TaskControl(ok=False) 不得记 scheduler.task_ok(Skip ≠ success)。"""
clock = [1_000.0]
logger = _CaptureLogger()
task = ScheduledTask(
"quiet",
60,
lambda: TaskControl(ok=False, next_run=1_500.0, error="snapshot_quiet_window"),
)
scheduler = Scheduler([task], clock=lambda: clock[0], logger=logger)
results = scheduler.run_due()
assert results[0].ok is False
assert results[0].error == "snapshot_quiet_window"
assert ("info", "scheduler.task_ok", {"task": "quiet"}) not in logger.events
fail_events = [e for e in logger.events if e[1] == "scheduler.task_fail"]
assert len(fail_events) == 1
assert fail_events[0][0] == "warning"
assert fail_events[0][2]["task"] == "quiet"
assert fail_events[0][2]["error"] == "snapshot_quiet_window"
def test_task_control_ok_true_still_logs_task_ok():
clock = [1_000.0]
logger = _CaptureLogger()
task = ScheduledTask(
"ok_task",
60,
lambda: TaskControl(ok=True, next_run=1_200.0),
)
scheduler = Scheduler([task], clock=lambda: clock[0], logger=logger)
results = scheduler.run_due()
assert results[0].ok is True
assert ("info", "scheduler.task_ok", {"task": "ok_task"}) in logger.events
assert not any(e[1] == "scheduler.task_fail" for e in logger.events)
def test_raised_exception_logs_task_fail():
clock = [100.0]
logger = _CaptureLogger()
def boom():
raise RuntimeError("db down")
scheduler = Scheduler(
[ScheduledTask("bad", 10, boom)],
clock=lambda: clock[0],
logger=logger,
)
results = scheduler.run_due()
assert results[0].ok is False
assert results[0].error == "db down"
assert not any(e[1] == "scheduler.task_ok" for e in logger.events)
assert not any(e[1] == "scheduler.task_fail" for e in logger.events)
err_events = [e for e in logger.events if e[1] == "scheduler.task_error"]
assert len(err_events) == 1
assert err_events[0][0] == "error"
assert err_events[0][2]["error"] == "db down"
def main():
test_run_when_due_and_reschedule()
test_next_run_fn_controls_reschedule()
test_task_control_overrides_ok_and_next_run()
test_task_control_ok_false_logs_fail_not_ok()
test_task_control_ok_true_still_logs_task_ok()
test_raised_exception_logs_task_fail()
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()