Files
re-xxm/reaction_board/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

142 lines
4.5 KiB
Python

# -*- coding: utf-8 -*-
"""单进程内置调度器。
P0 只注册收藏夹同步任务;P1/P3 会在同一调度器上追加快照、粉丝和发现任务。
调度只负责“什么时候运行”,具体任务保持幂等,并在自己的服务层处理重试。
任务 ``fn`` 可返回 :class:`TaskControl` 以覆盖 ``ok`` / ``next_run``
(用于静默窗延后、失败补跑等);未返回时仍按 ``next_run_fn`` / ``interval`` 排期。
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Any, Callable, Sequence
@dataclass
class TaskControl:
"""任务可选返回值:覆盖成功标记与下次运行时间。"""
ok: bool = True
next_run: float | None = None
error: str | None = None
@dataclass
class ScheduledTask:
name: str
interval: float
fn: Callable[[], Any]
next_run: float = 0.0
next_run_fn: Callable[[float], float] | None = None
@dataclass
class TaskResult:
name: str
started_at: float
finished_at: float
ok: bool
error: str | None = None
def as_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"started_at": int(self.started_at),
"finished_at": int(self.finished_at),
"ok": self.ok,
"error": self.error,
}
class Scheduler:
def __init__(
self,
tasks: Sequence[ScheduledTask],
*,
clock=time.time,
sleeper=time.sleep,
logger=None,
) -> None:
if not tasks:
raise ValueError("至少需要一个调度任务")
for task in tasks:
if task.interval <= 0:
raise ValueError(f"任务 {task.name} 的 interval 必须大于 0")
self.tasks = list(tasks)
self._clock = clock
self._sleeper = sleeper
self.logger = logger
def _log(self, level: str, event: str, **fields) -> None:
if self.logger is not None:
getattr(self.logger, level)(event, **fields)
def run_due(self, *, now: float | None = None) -> list[TaskResult]:
current = self._clock() if now is None else now
results: list[TaskResult] = []
for task in self.tasks:
if task.next_run > current:
continue
started = current
error: str | None = None
control: TaskControl | None = None
try:
raw = task.fn()
except Exception as exc: # noqa: BLE001 单任务失败不终止调度
error = str(exc)
self._log("error", "scheduler.task_error", task=task.name, error=error)
else:
if isinstance(raw, TaskControl):
control = raw
self._log("info", "scheduler.task_ok", task=task.name)
finished = self._clock()
if control is not None and control.next_run is not None:
task.next_run = float(control.next_run)
elif task.next_run_fn is not None:
task.next_run = task.next_run_fn(started)
else:
task.next_run = started + task.interval
ok = error is None
if control is not None:
ok = bool(control.ok) and error is None
if control.error and error is None:
error = control.error
result = TaskResult(
name=task.name,
started_at=started,
finished_at=finished,
ok=ok,
error=error,
)
results.append(result)
return results
def run_forever(
self,
*,
stop_event=None,
poll_seconds: float = 30.0,
max_iterations: int | None = None,
) -> list[TaskResult]:
"""阻塞运行;``stop_event`` 或 ``max_iterations`` 用于可控退出/测试。"""
if poll_seconds <= 0:
raise ValueError("poll_seconds 必须大于 0")
all_results: list[TaskResult] = []
iterations = 0
while True:
if stop_event is not None and stop_event.is_set():
break
all_results.extend(self.run_due())
iterations += 1
if max_iterations is not None and iterations >= max_iterations:
break
if stop_event is not None:
if stop_event.wait(poll_seconds):
break
else:
self._sleeper(poll_seconds)
return all_results