Files
re-xxm/reaction_board/scheduler.py
T
bot-ci f6bef59e07 fix(scheduler): daily slot off-plan + cross-midnight resume (#8)
- Cross-midnight: unfinished yesterday seals ASAP with yesterday's stat_date;
  force seal ignores today's snapshot_at gate when stat_date < today.
- Off-plan already_completed: compare completion started_at (not finished_at)
  to today's planned slot; earlier → still run at slot for all daily tasks.
- Cold start: bootstrap task:state from task:last / seed markers.
- ensure_pending: do not glue new due onto a different day's unfinished state.
- run_if_needed(force): keep_scraped True for seal, False for CLI --force.
- Scheduler due_reader: pick up disk due_at each loop (seed→evening seal).
2026-11-03 02:35:13 +08:00

164 lines
5.3 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 -*-
"""单进程内置调度器。
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,
due_reader: Callable[[str], float | None] | None = 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
# 每轮从落盘 due_at 刷新 next_run(seed 挂当晚 seal 等同进程可见)
self._due_reader = due_reader
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 self._due_reader is not None:
disk_due = self._due_reader(task.name)
if disk_due is not None:
task.next_run = float(disk_due)
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)
else:
if isinstance(raw, TaskControl):
control = raw
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
# Skip/fail ≠ success:先定 ok/error,再记 scheduler 日志。
if ok:
self._log("info", "scheduler.task_ok", task=task.name)
elif control is not None:
self._log(
"warning",
"scheduler.task_fail",
task=task.name,
error=error,
)
else:
self._log(
"error",
"scheduler.task_error",
task=task.name,
error=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