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

96 lines
3.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 -*-
"""上海时区与每日任务时间计算。
B 站数据口径使用 ``Asia/Shanghai``。中国自 1991 年起不再使用夏令时,因此这里
用固定 UTC+8,避免 Windows 上依赖未安装的 ``tzdata`` 包。
"""
from __future__ import annotations
import re
import time
from datetime import datetime, timedelta, timezone
SHANGHAI_TZ = timezone(timedelta(hours=8))
_HHMM_RE = re.compile(r"^([01]\d|2[0-3]):([0-5]\d)$")
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
def shanghai_datetime(ts: float | None = None) -> datetime:
return datetime.fromtimestamp(time.time() if ts is None else ts, SHANGHAI_TZ)
def shanghai_date(ts: float | None = None) -> str:
return shanghai_datetime(ts).strftime("%Y-%m-%d")
def parse_hhmm(value: str) -> tuple[int, int]:
match = _HHMM_RE.match(value or "")
if not match:
raise ValueError(f"时间必须是 HH:MM(24 小时制): {value!r}")
return int(match.group(1)), int(match.group(2))
def next_daily_run(now: float, hhmm: str) -> float:
"""返回 ``now`` 之后(严格大于)最近的 ``hh:mm`` 上海时间时间戳。"""
hour, minute = parse_hhmm(hhmm)
current = shanghai_datetime(now)
target = current.replace(hour=hour, minute=minute, second=0, microsecond=0)
if target.timestamp() <= now:
target += timedelta(days=1)
return target.timestamp()
def is_date(value: str) -> bool:
if not _DATE_RE.match(value or ""):
return False
try:
datetime.strptime(value, "%Y-%m-%d")
except ValueError:
return False
return True
def day_bounds(date: str) -> tuple[float, float]:
"""返回上海时区某一天的 [start, end) 时间戳。"""
if not is_date(date):
raise ValueError(f"日期格式必须是 YYYY-MM-DD: {date!r}")
start = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=SHANGHAI_TZ)
return start.timestamp(), (start + timedelta(days=1)).timestamp()
# 全站快照静默窗:上海时区 [23:00, 01:00)。此间 discovery / recent / cleanup 不执行。
SNAPSHOT_QUIET_START_HOUR = 23
SNAPSHOT_QUIET_END_HOUR = 1
def in_snapshot_quiet_window(ts: float | None = None) -> bool:
"""是否处于全站快照静默窗 [23:00, 01:00)(上海时区,跨日)。"""
dt = shanghai_datetime(ts)
return dt.hour >= SNAPSHOT_QUIET_START_HOUR or dt.hour < SNAPSHOT_QUIET_END_HOUR
def next_after_snapshot_quiet_window(ts: float) -> float:
"""若 ``ts`` 落在静默窗内,推到窗结束后的 01:00;否则原样返回。"""
if not in_snapshot_quiet_window(ts):
return ts
dt = shanghai_datetime(ts)
if dt.hour >= SNAPSHOT_QUIET_START_HOUR:
target = (dt + timedelta(days=1)).replace(
hour=SNAPSHOT_QUIET_END_HOUR, minute=0, second=0, microsecond=0,
)
else:
target = dt.replace(
hour=SNAPSHOT_QUIET_END_HOUR, minute=0, second=0, microsecond=0,
)
if target.timestamp() <= ts:
target += timedelta(days=1)
return target.timestamp()
def interval_next_run(now: float, interval: float) -> float:
"""``now + interval``,若落入静默窗则推到 01:00 之后。"""
if interval <= 0:
raise ValueError("interval 必须大于 0")
return next_after_snapshot_quiet_window(now + interval)