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:
1 parent
dcf37c4a1d
commit
f6bef59e07
7 files changed
+630
-24
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user