tests/test_follow_scheduler.py
Rama master · réplica de lectura del repositorio original.
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from peach.follow_scheduler import (
MAX_BACKOFF_FACTOR,
MAX_INTERVAL_MINUTES,
MIN_INTERVAL_MINUTES,
FollowScheduleConfig,
FollowScheduleStore,
FollowUpdateScheduler,
)
class FollowScheduleStoreTests(unittest.TestCase):
def test_defaults_are_enabled_hourly_and_updates_are_atomic(self):
with tempfile.TemporaryDirectory() as temporary:
store = FollowScheduleStore(Path(temporary))
self.assertEqual(store.load(), FollowScheduleConfig(True, 60))
store.save(FollowScheduleConfig(False, 180))
self.assertEqual(store.load(), FollowScheduleConfig(False, 180))
def test_unbounded_intervals_are_rejected(self):
with self.assertRaises(ValueError):
FollowScheduleStore.validate(True, 14)
with self.assertRaises(ValueError):
FollowScheduleStore.validate(True, 10081)
with self.assertRaises(ValueError):
FollowScheduleStore.validate("false", 60)
class FollowUpdateSchedulerTests(unittest.TestCase):
def test_restart_preserves_failed_checks_and_the_backoff_deadline(self):
failure = lambda: {'checked': 1, 'results': [{'ok': False}]}
with tempfile.TemporaryDirectory() as temporary:
first = FollowUpdateScheduler(Path(temporary), failure, available=True)
first.start()
try:
first._run()
first._run()
previous = first.status()
finally:
first.stop()
second = FollowUpdateScheduler(Path(temporary), failure, available=True)
second.start()
try:
current = second.status()
self.assertEqual(current['consecutive_failures'], 2)
self.assertEqual(current['last_error'], previous['last_error'])
self.assertEqual(current['failing_since'], previous['failing_since'])
self.assertEqual(current['next_run_at'], previous['next_run_at'])
second.run_check = lambda: {'checked': 1, 'results': [{'ok': True}]}
second._run()
finally:
second.stop()
third = FollowUpdateScheduler(Path(temporary), failure, available=True)
self.assertEqual(third.status()['consecutive_failures'], 0)
self.assertIsNone(third.status()['last_error'])
self.assertIsNone(third.status()['failing_since'])
def test_installation_reservation_skips_the_tick_without_recording_a_failure(self):
from peach.update_tasks import UpdateBusy
def blocked():
raise UpdateBusy('正在安装')
with tempfile.TemporaryDirectory() as temporary:
scheduler = FollowUpdateScheduler(Path(temporary), blocked, available=True)
with self.assertNoLogs('peach', level='ERROR'):
scheduler._run()
status = scheduler.status()
self.assertEqual(status['consecutive_failures'], 0)
self.assertIsNone(status['last_error'])
self.assertIsNotNone(status['last_skipped_at'])
def test_interval_job_waits_one_interval_and_can_be_disabled(self):
with tempfile.TemporaryDirectory() as temporary:
scheduler = FollowUpdateScheduler(Path(temporary), lambda: {"ok": True}, available=True)
self.addCleanup(scheduler.stop)
scheduler.start()
status = scheduler.status()
self.assertTrue(status["enabled"])
self.assertIsNotNone(status["next_run_at"])
status = scheduler.update(enabled=False, interval_minutes=60)
self.assertFalse(status["enabled"])
self.assertIsNone(status["next_run_at"])
def test_run_status_reports_checked_sources_and_additions(self):
result = {"ok": True, "checked": 2, "results": [
{"ok": True, "added": 3}, {"ok": False, "added": 0},
]}
with tempfile.TemporaryDirectory() as temporary:
scheduler = FollowUpdateScheduler(Path(temporary), lambda: result, available=True)
scheduler._run()
status = scheduler.status()
self.assertEqual(status["last_checked"], 2)
self.assertEqual(status["last_added"], 3)
self.assertEqual(status["last_error"], "1 个来源检查失败")
self.assertIsNotNone(status["last_finished_at"])
def test_a_busy_lock_is_a_skip_not_a_failure(self):
"""互斥不是故障:手动检查正在跑的时候,自动这一轮什么也没做。
这里抛异常的话,界面会把它显示成「上次失败」,还连带触发退避——真正的
失败反而被这种噪声盖住。"""
with tempfile.TemporaryDirectory() as temporary:
scheduler = FollowUpdateScheduler(
Path(temporary), lambda: {"ok": False, "busy": True,
"checked": 0, "results": []},
available=True)
scheduler._run()
status = scheduler.status()
self.assertIsNone(status["last_error"])
self.assertIsNotNone(status["last_skipped_at"])
self.assertEqual(status["consecutive_failures"], 0)
# 这一轮既没检查也没完成,所以不留「上次运行」的时间戳:
# 留了界面就会把一次没发生的运行显示成「上次完成」。
self.assertIsNone(status["last_started_at"])
self.assertIsNone(status["last_finished_at"])
self.assertEqual(status["last_checked"], 0)
def test_consecutive_failures_back_off_and_a_good_run_resets_them(self):
outcomes = [
{"ok": True, "checked": 1, "results": [{"ok": False, "added": 0}]},
RuntimeError("上游断了"),
{"ok": True, "checked": 1, "results": [{"ok": True, "added": 2}]},
]
def run_check():
outcome = outcomes.pop(0)
if isinstance(outcome, Exception):
raise outcome
return outcome
with tempfile.TemporaryDirectory() as temporary:
scheduler = FollowUpdateScheduler(Path(temporary), run_check,
available=True)
interval = scheduler.config.interval_minutes
# 第一次失败照原间隔重试:多数失败只是一次抖动。
self.assertEqual(scheduler.backoff_minutes(1), interval)
self.assertEqual(scheduler.backoff_minutes(2), interval * 2)
self.assertEqual(scheduler.backoff_minutes(3), interval * 4)
self.assertEqual(scheduler.backoff_minutes(99),
min(interval * MAX_BACKOFF_FACTOR,
MAX_INTERVAL_MINUTES))
scheduler._run()
first = scheduler.status()
self.assertEqual(first["consecutive_failures"], 1)
self.assertEqual(first["failing_since"], first["last_started_at"])
with self.assertLogs("peach.follow_scheduler", level="ERROR") as captured:
scheduler._run()
self.assertIn("上游断了", "\n".join(captured.output))
status = scheduler.status()
self.assertEqual(status["consecutive_failures"], 2)
self.assertEqual(status["last_error"], "上游断了")
# 连续失败的起点停在第一轮,不跟着每一轮往后挪。
self.assertEqual(status["failing_since"], first["failing_since"])
scheduler._run()
status = scheduler.status()
self.assertEqual(status["consecutive_failures"], 0)
self.assertIsNone(status["last_error"])
self.assertIsNone(status["failing_since"])
self.assertEqual(status["last_added"], 2)
def test_run_now_runs_the_scheduled_job_and_settles_its_failure(self):
import threading
finished = threading.Event()
outcomes = [{"ok": True, "checked": 1, "results": [{"ok": False}]},
{"ok": True, "checked": 1, "results": [{"ok": True, "added": 1}]}]
def run_check():
return outcomes.pop(0)
with tempfile.TemporaryDirectory() as temporary:
scheduler = FollowUpdateScheduler(Path(temporary), run_check, available=True)
self.addCleanup(scheduler.stop)
with self.assertRaises(ValueError):
scheduler.run_now()
scheduler._run()
self.assertEqual(scheduler.status()["last_error"], "1 个来源检查失败")
original = scheduler._run
def tracked():
original()
finished.set()
scheduler._run = tracked
scheduler.start()
# 提前标成运行中,读状态的人不会在调度线程领走之前再看到旧的失败。
self.assertTrue(scheduler.run_now()["running"])
self.assertTrue(finished.wait(10))
status = scheduler.status()
self.assertEqual((status["running"], status["last_error"], status["consecutive_failures"]),
(False, None, 0))
self.assertIsNotNone(status["next_run_at"])
def test_run_now_is_refused_without_a_running_job(self):
with tempfile.TemporaryDirectory() as temporary:
reader = FollowUpdateScheduler(Path(temporary), lambda: {"ok": True}, available=False)
with self.assertRaises(ValueError):
reader.run_now()
writer = FollowUpdateScheduler(Path(temporary), lambda: {"ok": True}, available=True)
self.addCleanup(writer.stop)
writer.start()
writer.update(enabled=False, interval_minutes=60)
with self.assertRaises(ValueError):
writer.run_now()
self.assertFalse(writer.status()["running"])
def test_backoff_pushes_the_next_run_past_the_plain_interval(self):
with tempfile.TemporaryDirectory() as temporary:
scheduler = FollowUpdateScheduler(
Path(temporary),
lambda: {"ok": True, "checked": 1,
"results": [{"ok": False, "added": 0}]},
available=True)
self.addCleanup(scheduler.stop)
scheduler.update(enabled=True, interval_minutes=MIN_INTERVAL_MINUTES)
scheduler.start()
planned = datetime.now(timezone.utc) + timedelta(
minutes=MIN_INTERVAL_MINUTES)
scheduler._run()
scheduler._run()
next_run = scheduler.status()["next_run_at"]
self.assertIsNotNone(next_run)
self.assertGreater(datetime.fromisoformat(next_run), planned,
"连续失败后应当推后,而不是照原间隔继续敲上游")
def test_a_tick_shorter_than_the_period_runs_often_and_backs_off_from_the_tick(self):
"""订阅源:设置里的间隔是每个源的周期,调度按更短的一跳看谁到期(ADR-0134)。"""
with tempfile.TemporaryDirectory() as temporary:
scheduler = FollowUpdateScheduler(
Path(temporary), lambda: {"ok": True, "checked": 0, "results": []},
available=True, default=FollowScheduleConfig(True, 360), tick_minutes=30)
self.addCleanup(scheduler.stop)
before = datetime.now(timezone.utc)
scheduler.start()
status = scheduler.status()
self.assertEqual(status["interval_minutes"], 360)
self.assertLess(datetime.fromisoformat(status["next_run_at"]),
before + timedelta(minutes=31))
self.assertEqual(scheduler.backoff_minutes(2), 60)
def test_an_idle_tick_keeps_the_last_failure_on_record(self):
"""没有到期源的一跳什么也没做:上一轮的失败、连续失败计数和上次运行时间都留着。"""
outcomes = [{"ok": True, "checked": 1, "results": [{"ok": False, "added": 0}]},
{"ok": True, "idle": True, "checked": 0, "results": []}]
with tempfile.TemporaryDirectory() as temporary:
scheduler = FollowUpdateScheduler(Path(temporary), lambda: outcomes.pop(0),
available=True, tick_minutes=30)
scheduler._run()
failed = scheduler.status()
scheduler._run()
status = scheduler.status()
self.assertEqual(status["last_error"], "1 个来源检查失败")
self.assertEqual(status["consecutive_failures"], 1)
self.assertEqual((status["last_started_at"], status["last_finished_at"], status["last_checked"]),
(failed["last_started_at"], failed["last_finished_at"], 1))
self.assertFalse(status["running"])
def test_reader_never_starts_or_accepts_schedule_changes(self):
with tempfile.TemporaryDirectory() as temporary:
scheduler = FollowUpdateScheduler(Path(temporary), lambda: {}, available=False)
scheduler.start()
self.assertFalse(scheduler.status()["available"])
with self.assertRaises(ValueError):
scheduler.update(enabled=True, interval_minutes=60)