Implement P1: seven-metric view snapshots, relation/stat fan snapshots, failure placeholders, resume by daily completion markers, Cookie login warnings, daily SQLite backups, Shanghai daily scheduling, and CLI snapshot/auth-check commands. Real read-only run captured 795/795 videos successfully over 1124s with a verified daily backup.
120 lines
3.6 KiB
Python
120 lines
3.6 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""单进程内置调度器。
|
|
|
|
P0 只注册收藏夹同步任务;P1/P3 会在同一调度器上追加快照、粉丝和发现任务。
|
|
调度只负责“什么时候运行”,具体任务保持幂等,并在自己的服务层处理重试。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Any, Callable, Sequence
|
|
|
|
|
|
@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
|
|
try:
|
|
task.fn()
|
|
except Exception as exc: # noqa: BLE001 单任务失败不终止调度
|
|
error = str(exc)
|
|
self._log("error", "scheduler.task_error", task=task.name, error=error)
|
|
else:
|
|
self._log("info", "scheduler.task_ok", task=task.name)
|
|
finished = self._clock()
|
|
if task.next_run_fn is not None:
|
|
task.next_run = task.next_run_fn(started)
|
|
else:
|
|
task.next_run = started + task.interval
|
|
result = TaskResult(
|
|
name=task.name,
|
|
started_at=started,
|
|
finished_at=finished,
|
|
ok=error is None,
|
|
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
|