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
This commit is contained in:
bot-ci committed 2026-11-03 00:07:19 +08:00
1 parent 39378c65b2
commit 9969e65202
10 files changed
+411 -84

No files matched your search

+37 -7
View File
@@ -34,7 +34,14 @@ from ..analytics import (
)
from ..config import ConfigStore
from ..database import Database
from ..timeutil import day_bounds, next_daily_run, shanghai_date, shanghai_datetime
from ..timeutil import (
day_bounds,
interval_next_run,
next_after_snapshot_quiet_window,
next_daily_run,
shanghai_date,
shanghai_datetime,
)
from .cache import DASHBOARD_CACHE, TTL_FANS, TTL_VIDEOS, build_cache_key
TRACKED_STATUSES = ("active", "unfaved")
@@ -1971,9 +1978,10 @@ class DashboardQueries:
"fans": int(next_daily_run(now, cfg.schedule.snapshot_at)),
"snapshot": int(next_daily_run(now, cfg.schedule.snapshot_at)),
"snapshot_seed": int(next_daily_run(now, cfg.schedule.seed_at)),
"discover": int(now + discover_interval),
"snapshot_recent": int(now + recent_interval),
"discover": int(interval_next_run(now, float(discover_interval))),
"snapshot_recent": int(interval_next_run(now, float(recent_interval))),
}
retry_seconds = 300
for name, key, at in (
("fans", "fans", cfg.schedule.snapshot_at),
("snapshot", "snapshot", cfg.schedule.snapshot_at),
@@ -1985,19 +1993,41 @@ class DashboardQueries:
if not (isinstance(record, dict) and record.get("finished_at")):
continue
finished_at = int(record["finished_at"])
summary = record.get("summary") if isinstance(record.get("summary"), dict) else {}
skipped = bool(record.get("skipped") or summary.get("skipped"))
failed = record.get("ok") is False
if name in {"discover", "snapshot_recent"}:
# 只按上次完成推下一档;进行中由 running 展示,不按墙钟抢跑。
# 上次完成推下一档;skip/失败短补跑;静默窗推到 01:00 后。
interval = (
recent_interval if name == "snapshot_recent"
else discover_interval
)
next_runs[name] = finished_at + interval
if skipped:
next_runs[name] = int(
next_after_snapshot_quiet_window(float(finished_at))
)
elif failed:
next_runs[name] = int(finished_at + retry_seconds)
else:
next_runs[name] = int(
interval_next_run(float(finished_at), float(interval))
)
else:
next_runs[name] = int(next_daily_run(finished_at, at))
if skipped or failed:
# 日更任务失败/skip:短间隔补跑,绝不按成功推到次日。
next_runs[name] = int(finished_at + retry_seconds)
else:
next_runs[name] = int(next_daily_run(finished_at, at))
errors = [
{"task": name, **record}
for name, record in tasks.items()
if isinstance(record, dict) and record.get("ok") is False
if isinstance(record, dict)
and record.get("ok") is False
and not record.get("skipped")
and not (
isinstance(record.get("summary"), dict)
and record["summary"].get("skipped")
)
]
counts = {str(row["status"]): int(row["n"]) for row in conn.execute(
"SELECT status, COUNT(*) AS n FROM video GROUP BY status"
+16 -3
View File
@@ -5,6 +5,15 @@ import {
escapeHtml, formatCount, formatDateTime, formatNumber,
} from "../format.js";
function taskResultLabel(record) {
const summary = record.summary && typeof record.summary === "object" ? record.summary : {};
if (record.skipped || summary.skipped) {
return `<span class="muted">跳过</span>`;
}
if (record.ok) return "成功";
return `<span class="danger">失败</span>`;
}
function taskRows(tasks) {
const entries = Object.entries(tasks || {});
if (!entries.length) return emptyState("暂无任务记录", "运行一次同步、快照或发现后会写入最近结果。");
@@ -15,12 +24,16 @@ function taskRows(tasks) {
${entries.map(([name, record]) => {
const summary = record.summary && typeof record.summary === "object"
? Object.entries(record.summary)
.filter(([key]) => ["added", "removed", "candidates", "notified", "ok", "failed", "total", "completed", "backup_error"].includes(key))
.filter(([key]) => [
"added", "removed", "candidates", "notified", "ok", "failed",
"total", "completed", "backup_error", "skipped", "already_completed",
"reason", "elapsed", "success",
].includes(key))
.map(([key, value]) => `${key}=${value}`).join(" ")
: "";
return `<tr>
<td>${escapeHtml(name)}</td>
<td>${record.ok ? "成功" : `<span class="danger">失败</span>`}</td>
<td>${taskResultLabel(record)}</td>
<td>${escapeHtml(formatDateTime(record.finished_at))}</td>
<td class="summary-cell">${escapeHtml(summary || record.error || "—")}</td>
</tr>`;
@@ -100,7 +113,7 @@ export async function renderSystem(root) {
<div><span>新视频小时同步</span><strong>${running.snapshot_recent ? "运行中" : escapeHtml(formatDateTime(next.snapshot_recent))}</strong></div>
<div><span>新视频搜索</span><strong>${running.discover || running.discovery ? "运行中" : escapeHtml(formatDateTime(next.discover))}</strong></div>
</div>
<p class="section-note">进行中显示「运行中」;空闲时按该任务上次完成时间推算下次运行,不按墙钟抢跑。新视频小时同步与全站快照互斥。</p>
<p class="section-note">进行中显示「运行中」;空闲时按该任务上次完成时间推算下次运行。23:00–01:00(上海)为全站快照窗:发现 / 小时同步 / 候选清理会延后到 01:00 后;粉丝更新不避开。跳过或失败不会记成成功,会短间隔补跑。</p>
</section>
<section class="panel">