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).
This commit is contained in:
bot-ci committed 2026-11-03 02:35:13 +08:00
1 parent dcf37c4a1d
commit f6bef59e07
7 files changed
+630 -24

No files matched your search

+125 -13
View File
@@ -377,14 +377,19 @@ def _initial_daily_next_run(
) -> float:
"""进程启动时日更任务的下次运行:优先 ``task:state.due_at``。
不得返回 ``0`` / 用「无状态」以外的过去时间去误伤——无状态时对齐墙钟。
失败且 ``due_at`` 已过:返回该 ``due_at``(调度器会立刻补跑),不退回次日墙钟。
无状态时从 ``task:last`` / seed 标记冷启动回填;不得返回 ``0`` 误伤。
失败且 ``due_at`` 已过:立刻补跑,不退回次日墙钟。
"""
del retry_seconds # 间隔已落在 task:state.due_at
current = time.time() if now is None else float(now)
wall = next_daily_run(current, at)
retry = float(retry_seconds) if retry_seconds is not None else _TASK_RETRY_SECONDS
return task_state_mod.startup_next_run(
repo, name, wall_due_at=wall, now=current,
repo,
name,
wall_due_at=wall,
now=current,
slot_hhmm=at,
retry_seconds=retry,
)
@@ -396,6 +401,67 @@ def _reached_daily_slot(at: str, now: float | None = None) -> bool:
return current >= slot
def _today_slot_ts(at: str, now: float | None = None) -> float:
"""今日 ``at`` 墙钟点的时间戳(不论是否已过)。"""
current = shanghai_datetime(now)
hour, minute = parse_hhmm(at)
return current.replace(hour=hour, minute=minute, second=0, microsecond=0).timestamp()
def _last_success_started_at(repo: Repository, name: str) -> float | None:
"""最近一次成功完成记录的 ``started_at``(不用 finished_at)。"""
try:
raw = repo.kv_get(f"task:last:{name}")
except Exception: # noqa: BLE001
return None
if not raw:
return None
try:
data = json.loads(raw)
except (TypeError, ValueError, json.JSONDecodeError):
return None
if not isinstance(data, dict):
return None
if not data.get("ok") or data.get("skipped"):
return None
try:
return float(data["started_at"])
except (KeyError, TypeError, ValueError):
return None
def _off_plan_completion(
repo: Repository,
name: str,
at: str,
now: float | None = None,
) -> bool:
"""成功完成的 started_at 早于当日计划点 → 非计划内,到点仍应再跑。
产品规则:只用完成记录的 **started_at** 与今日 ``at`` 比较,禁止 finished_at。
"""
started = _last_success_started_at(repo, name)
if started is None:
return False
return float(started) < _today_slot_ts(at, now)
def _should_force_daily_seal(
*,
seeded: bool,
stat_date: str,
today: str,
slot_hhmm: str,
now: float,
) -> bool:
"""seed 壳是否 force 打穿:跨天未完成立即 force;同日须已到计划点。"""
if not seeded:
return False
if stat_date < today:
return True
return _reached_daily_slot(slot_hhmm, now)
def _evening_due_at(now: float, snapshot_at: str) -> float:
"""尚未到当日 snapshot_at 则挂今晚,否则下一档墙钟。"""
hour, minute = parse_hhmm(snapshot_at)
@@ -535,7 +601,11 @@ def cmd_snapshot(args, logger: JsonLogger) -> int:
stat_date=shanghai_date(float(started)),
)
try:
video_summary = service.run_if_needed(force=args.force)
# CLI --force:全量重采(keep_scraped=False);非 force 走常规门闩
video_summary = service.run_if_needed(
force=args.force,
keep_scraped=False if args.force else None,
)
except Exception as exc: # noqa: BLE001
finished = int(time.time())
_record_task(
@@ -1014,16 +1084,33 @@ def cmd_run(args, logger: JsonLogger, *, serve_web: bool = False) -> int:
repo, "snapshot", started_at=started, stat_date=stat_date,
)
try:
# seed 壳 ≠ 白天强采:仅到达当日 snapshot_at(或显式续跑已到期的
# seal pending)才 force。due_at 门闩负责「会不会跑」;这里是
# force 的第二道闸。
# seed 壳 force:跨天未完成(stat_date < today)立刻打穿;
# 同日仅到达 snapshot_at 后才 force。due_at 管「会不会跑」。
seeded = repo.kv_get(SnapshotService.seeded_key(stat_date)) == "1"
force_seed = seeded and _reached_daily_slot(
snapshot_at, float(started),
force_seed = _should_force_daily_seal(
seeded=seeded,
stat_date=stat_date,
today=today,
slot_hhmm=snapshot_at,
now=float(started),
)
summary = snapshot_service.run_if_needed(
date=stat_date, force=force_seed,
date=stat_date,
force=force_seed,
keep_scraped=True if force_seed else None,
)
# 日更规则:already_completed 但完成 started_at 早于今日计划点
# → 非计划内,到点仍按计划再跑(全量,不用 finished_at)。
if (
summary.already_completed
and _reached_daily_slot(snapshot_at, float(started))
and _off_plan_completion(
repo, "snapshot", snapshot_at, float(started),
)
):
summary = snapshot_service.run_if_needed(
date=stat_date, force=True, keep_scraped=False,
)
except Exception as exc: # noqa: BLE001
finished = int(time.time())
next_run = _retry_soon(float(finished))
@@ -1138,12 +1225,26 @@ def cmd_run(args, logger: JsonLogger, *, serve_web: bool = False) -> int:
seeded = (
repo.kv_get(SnapshotService.fans_seeded_key(stat_date)) == "1"
)
force_seed = seeded and _reached_daily_slot(
snapshot_at, float(started),
force_seed = _should_force_daily_seal(
seeded=seeded,
stat_date=stat_date,
today=today,
slot_hhmm=snapshot_at,
now=float(started),
)
summary = snapshot_service.run_fans_if_needed(
date=stat_date, force=force_seed,
)
if (
summary.already_completed
and _reached_daily_slot(snapshot_at, float(started))
and _off_plan_completion(
repo, "fans", snapshot_at, float(started),
)
):
summary = snapshot_service.run_fans_if_needed(
date=stat_date, force=True,
)
except Exception as exc: # noqa: BLE001
finished = int(time.time())
next_run = _retry_soon(float(finished))
@@ -1287,6 +1388,16 @@ def cmd_run(args, logger: JsonLogger, *, serve_web: bool = False) -> int:
cleanup_next = task_state_mod.startup_next_run(
repo, "candidate_cleanup", wall_due_at=now_ts, now=now_ts,
)
def _due_reader(task_name: str) -> float | None:
state = task_state_mod.load_state(repo, task_name)
if state is None:
return None
try:
due = float(state.due_at or 0)
except (TypeError, ValueError):
return None
return due if due > 0 else None
scheduler = Scheduler(
[
ScheduledTask(
@@ -1328,6 +1439,7 @@ def cmd_run(args, logger: JsonLogger, *, serve_web: bool = False) -> int:
ScheduledTask("config_watch", 60.0, run_config_watch),
],
logger=logger,
due_reader=_due_reader,
)
if serve_web and not args.once:
+7
View File
@@ -59,6 +59,7 @@ class Scheduler:
clock=time.time,
sleeper=time.sleep,
logger=None,
due_reader: Callable[[str], float | None] | None = None,
) -> None:
if not tasks:
raise ValueError("至少需要一个调度任务")
@@ -69,6 +70,8 @@ class Scheduler:
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:
@@ -78,6 +81,10 @@ class Scheduler:
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
+11 -3
View File
@@ -142,12 +142,20 @@ class SnapshotService:
return f"fans:seeded:{stat_date}"
def run_if_needed(
self, *, date: str | None = None, force: bool = False
self,
*,
date: str | None = None,
force: bool = False,
keep_scraped: bool | None = None,
) -> SnapshotSummary:
"""视频快照(含 UP 头像补齐);粉丝采集请用 ``run_fans_if_needed``。
当日若仅有午夜 seed 壳且未 ``force``,直接返回等待晚间落盘,
避免把「全员 skip」误标成 completed。
``force`` + ``keep_scraped``:
- 晚间 seal / 断点续跑:``keep_scraped=True``(默认)保留已采 ok=1;
- CLI ``--force``:显式 ``keep_scraped=False`` 全量重采。
"""
now = self._now()
stat_date = date or shanghai_date(now)
@@ -155,9 +163,9 @@ class SnapshotService:
raise ValueError(f"日期格式必须是 YYYY-MM-DD: {stat_date!r}")
seeded = self.repo.kv_get(self.seeded_key(stat_date)) == "1"
if force:
# 晚间 seal / 续跑:打穿 seed,保留已真实采集的 ok=1 作为进度。
ks = True if keep_scraped is None else bool(keep_scraped)
return self.run_videos(
date=stat_date, force=True, keep_scraped=True,
date=stat_date, force=True, keep_scraped=ks,
)
completed = self.repo.kv_get(self.completed_key(stat_date)) == "1"
+155 -8
View File
@@ -10,10 +10,12 @@ from __future__ import annotations
import json
import time
from dataclasses import asdict, dataclass, field
from dataclasses import asdict, dataclass
from datetime import timedelta
from typing import Any, Mapping
from .repository import Repository
from .timeutil import parse_hhmm, shanghai_date, shanghai_datetime
STATUS_PENDING = "pending"
STATUS_RUNNING = "running"
@@ -219,13 +221,36 @@ def ensure_pending(
) -> TaskState:
"""保证存在一条 pending(如 seed 成功后挂上当晚 seal)。
若已有未完成状态且 due_at 更早,保留更早的 due_at;
同日未完成:取更早的 due_at。
跨日:不得把新一天的 due 粘到昨天的 ``stat_date`` 上——
若已有更早日期的未完成记录,保留它(跨天续跑优先);
若已有更新日期的未完成,则重置为调用方的 due/stat_date。
``overwrite_done`` 时覆盖已完成记录。
"""
current = _now(now)
prev = load_state(repo, name)
if prev is not None and prev.status in _ACTIVE and prev.status != STATUS_RUNNING:
# 未完成:取更早的 due_at,补上缺失的 stat_date
if (
stat_date is not None
and prev.stat_date
and prev.stat_date != stat_date
):
# 未完成的更早归属日优先保留,避免 seed 挂今晚时冲掉昨天续跑。
if prev.stat_date < stat_date:
return prev
# prev 归属日更新:按调用方重置,禁止跨日字段拼接。
state = TaskState(
name=name,
status=STATUS_PENDING,
due_at=float(due_at),
stat_date=stat_date,
progress=None,
updated_at=current,
error=None,
)
save_state(repo, state)
return state
# 同日(或缺一边 stat_date):取更早 due_at,补上缺失的 stat_date
due = min(float(prev.due_at or due_at), float(due_at))
sd = prev.stat_date or stat_date
if due == prev.due_at and sd == prev.stat_date and prev.status == STATUS_PENDING:
@@ -253,23 +278,145 @@ def ensure_pending(
return state
def bootstrap_from_last(
repo: Repository,
name: str,
*,
wall_due_at: float,
now: float | None = None,
retry_seconds: float = 300.0,
) -> TaskState | None:
"""升级/冷启动:无 ``task:state`` 时从 ``task:last`` 回填。
- 成功 → ``done`` + ``due_at=wall_due_at``
- 失败/跳过 → ``failed``/``deferred``,``due_at`` 为补跑点(已过则立刻)
"""
current = _now(now)
existing = load_state(repo, name)
if existing is not None:
return existing
raw = repo.kv_get(f"task:last:{name}")
if not raw:
return None
try:
data = json.loads(raw)
except (TypeError, ValueError, json.JSONDecodeError):
return None
if not isinstance(data, dict):
return None
ok = bool(data.get("ok"))
skipped = bool(data.get("skipped"))
try:
finished = float(data.get("finished_at") or data.get("started_at") or current)
except (TypeError, ValueError):
finished = current
summary = data.get("summary")
stat_date = None
if isinstance(summary, dict) and summary.get("stat_date"):
stat_date = str(summary["stat_date"])
error = data.get("error")
error_s = str(error) if error else None
if ok and not skipped:
state = TaskState(
name=name,
status=STATUS_DONE,
due_at=float(wall_due_at),
stat_date=stat_date,
updated_at=current,
error=None,
)
save_state(repo, state)
return state
due = finished + float(retry_seconds)
if due > current:
pass
else:
due = current
state = TaskState(
name=name,
status=STATUS_DEFERRED if skipped else STATUS_FAILED,
due_at=float(due),
stat_date=stat_date,
updated_at=current,
error=error_s,
)
save_state(repo, state)
return state
def bootstrap_seed_markers(
repo: Repository,
name: str,
*,
slot_hhmm: str,
wall_due_at: float,
now: float | None = None,
) -> TaskState | None:
"""无 state 时:若存在未 seal 的 seed 壳,挂 pending(跨天优先昨天)。
已过当日 ``slot_hhmm``(或归属日早于今天)→ ``due_at=now`` 尽快补跑。
"""
del wall_due_at # 未完成 seed 不走纯墙钟
current = _now(now)
existing = load_state(repo, name)
if existing is not None:
return existing
if name not in {"snapshot", "fans"}:
return None
today = shanghai_date(current)
yday = (shanghai_datetime(current) - timedelta(days=1)).strftime("%Y-%m-%d")
hour, minute = parse_hhmm(slot_hhmm)
today_slot = shanghai_datetime(current).replace(
hour=hour, minute=minute, second=0, microsecond=0,
).timestamp()
def _seeded_unfinished(day: str) -> bool:
if name == "snapshot":
if repo.kv_get(f"snapshot:seeded:{day}") != "1":
return False
return repo.kv_get(f"snapshot:completed:{day}") != "1"
# fans:seed 键在成功 seal 后删除,有键即未完成
return repo.kv_get(f"fans:seeded:{day}") == "1"
for day in (yday, today):
if not _seeded_unfinished(day):
continue
if day < today or current >= today_slot:
due = current
else:
due = float(today_slot)
return ensure_pending(
repo, name, due_at=due, stat_date=day, now=current,
)
return None
def startup_next_run(
repo: Repository,
name: str,
*,
wall_due_at: float,
now: float | None = None,
slot_hhmm: str | None = None,
retry_seconds: float = 300.0,
) -> float:
"""常驻启动时的 ``ScheduledTask.next_run``:只认落盘状态。
- 无状态时先 ``bootstrap_from_last`` / seed 标记回填
- 恢复残留 ``running`` → ``pending``
- ``due_at > now`` → 原样返回(失败延时不会被提前)
- ``due_at <= now`` 且未完成 → 立刻可跑(返回 ``due_at``,可为过去时间)
- ``done`` 且 ``due_at > now`` → 等下一次
- ``done`` 且 ``due_at <= now`` → 到期,返回 ``due_at``
- 无状态 → 墙钟 ``wall_due_at``
- ``due_at`` 是唯一时间门闩(失败延时不会被提前)
"""
current = _now(now)
if load_state(repo, name) is None:
bootstrap_from_last(
repo, name, wall_due_at=wall_due_at, now=current,
retry_seconds=retry_seconds,
)
if slot_hhmm:
bootstrap_seed_markers(
repo, name, slot_hhmm=slot_hhmm,
wall_due_at=wall_due_at, now=current,
)
state = recover_stale_running(repo, name, now=current)
if state is None:
return float(wall_due_at)