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

+131
View File
@@ -805,6 +805,134 @@ def test_daytime_seed_marker_does_not_force_without_slot():
db.close()
def test_off_plan_completion_uses_started_at_not_finished_at():
"""规则2:完成记录 started_at 早于今日计划点 → off-plan;禁用 finished_at。"""
from datetime import datetime
from reaction_board.timeutil import SHANGHAI_TZ
with tempfile.TemporaryDirectory() as tmp:
path = write_config(tmp, cookie="", csrf="")
assert run_cli(["init", "--config", str(path)])[0] == 0
db = Database(Path(tmp) / "app.db")
db.initialize()
try:
repo = Repository(db)
slot = datetime(2026, 11, 3, 23, 15, tzinfo=SHANGHAI_TZ).timestamp()
# 白天开始、晚上才结束:started_at < slot,finished_at > slot
day_start = datetime(2026, 11, 3, 10, 0, tzinfo=SHANGHAI_TZ).timestamp()
day_end = datetime(2026, 11, 3, 23, 30, tzinfo=SHANGHAI_TZ).timestamp()
repo.kv_set(
"task:last:snapshot",
json.dumps({
"name": "snapshot",
"ok": True,
"skipped": False,
"started_at": int(day_start),
"finished_at": int(day_end),
"error": None,
"summary": {"stat_date": "2026-11-03"},
}, ensure_ascii=False),
)
assert cli._last_success_started_at(repo, "snapshot") == float(int(day_start))
assert cli._off_plan_completion(repo, "snapshot", "23:15", slot) is True
# 若误用 finished_at 会判成计划内;started_at 必须仍为 off-plan
assert day_end >= slot
assert day_start < slot
# 计划内:started_at == slot → 不可 off-plan
repo.kv_set(
"task:last:snapshot",
json.dumps({
"name": "snapshot",
"ok": True,
"skipped": False,
"started_at": int(slot),
"finished_at": int(slot + 600),
"error": None,
"summary": {"stat_date": "2026-11-03"},
}, ensure_ascii=False),
)
assert cli._off_plan_completion(repo, "snapshot", "23:15", slot) is False
assert cli._off_plan_completion(
repo, "snapshot", "23:15", slot + 60,
) is False
finally:
db.close()
def test_cross_midnight_force_seal_ignores_todays_slot_gate():
"""规则1:昨天 seed 未完成,跨日后即便未到今日 23:15 也 force。"""
from datetime import datetime
from reaction_board.timeutil import SHANGHAI_TZ
now = datetime(2026, 11, 3, 0, 10, tzinfo=SHANGHAI_TZ).timestamp()
assert cli._reached_daily_slot("23:15", now) is False
assert cli._should_force_daily_seal(
seeded=True,
stat_date="2026-11-02",
today="2026-11-03",
slot_hhmm="23:15",
now=now,
) is True
# 同日白天仍不得 force
day = datetime(2026, 11, 3, 12, 0, tzinfo=SHANGHAI_TZ).timestamp()
assert cli._should_force_daily_seal(
seeded=True,
stat_date="2026-11-03",
today="2026-11-03",
slot_hhmm="23:15",
now=day,
) is False
night = datetime(2026, 11, 3, 23, 15, tzinfo=SHANGHAI_TZ).timestamp()
assert cli._should_force_daily_seal(
seeded=True,
stat_date="2026-11-03",
today="2026-11-03",
slot_hhmm="23:15",
now=night,
) is True
def test_initial_daily_bootstraps_failed_last_without_state():
"""A: 无 state、仅 task:last 失败 → 启动 next_run 立刻可补,不退墙钟。"""
from datetime import datetime
from reaction_board.timeutil import SHANGHAI_TZ, next_daily_run
from reaction_board.task_state import STATUS_FAILED, load_state
with tempfile.TemporaryDirectory() as tmp:
path = write_config(tmp, cookie="", csrf="")
assert run_cli(["init", "--config", str(path)])[0] == 0
db = Database(Path(tmp) / "app.db")
db.initialize()
try:
repo = Repository(db)
now = datetime(2026, 11, 3, 18, 0, tzinfo=SHANGHAI_TZ).timestamp()
repo.kv_set(
"task:last:snapshot",
json.dumps({
"name": "snapshot",
"ok": False,
"skipped": False,
"started_at": int(now - 900),
"finished_at": int(now - 800),
"error": "net",
"summary": {"stat_date": "2026-11-03"},
}, ensure_ascii=False),
)
wall = next_daily_run(now, "23:15")
nxt = cli._initial_daily_next_run(
repo, "snapshot", "23:15", now=now,
)
assert nxt <= now
assert nxt != wall
state = load_state(repo, "snapshot")
assert state is not None
assert state.status == STATUS_FAILED
finally:
db.close()
def main_test():
test_init_and_status()
test_missing_config()
@@ -830,6 +958,9 @@ def main_test():
test_reached_daily_slot_gates_force_seed()
test_restart_after_seed_schedules_snapshot_at_wall_clock()
test_daytime_seed_marker_does_not_force_without_slot()
test_off_plan_completion_uses_started_at_not_finished_at()
test_cross_midnight_force_seal_ignores_todays_slot_gate()
test_initial_daily_bootstraps_failed_last_without_state()
print("OK: CLI init/sync/snapshot/snapshot-seed/auth-check/run/status/backfill/错误路径测试全部通过")
+69
View File
@@ -705,6 +705,73 @@ def test_seal_keep_scraped_skips_view_progress_punches_seed():
db.close()
def test_force_keep_scraped_false_rescrapes_view_progress():
"""C: CLI --force 路径 keep_scraped=False 须重采已有 view 进度行。"""
clock = 1_800_000_000
with tempfile.TemporaryDirectory() as tmp:
transport = SnapshotTransport({
1: view_for(1, view=999),
2: view_for(2, view=222),
})
db, repo, service = make_service(tmp, transport)
try:
add_video(repo, 1)
add_video(repo, 2)
today = DATE
for aid, view, src in ((1, 111, "view"), (2, 20, "seed")):
repo.upsert_snapshot(
today, aid,
metrics={"view": view, "like": 1, "coin": 0, "favorite": 0,
"share": 0, "reply": 0, "danmaku": 0},
ok=True, err=None, captured_at=clock, src=src,
)
repo.kv_set(service.seeded_key(today), "1")
forced = service.run_if_needed(
date=today, force=True, keep_scraped=False,
)
assert forced.completed is True
# 两条都重采
assert transport.count("/web-interface/view") == 2
assert int(repo.get_snapshot(today, 1)["view"]) == 999
finally:
db.close()
def test_force_default_keep_scraped_skips_view_progress():
"""C: force 默认 keep_scraped=True(seal)跳过已 view 行。"""
clock = 1_800_000_000
with tempfile.TemporaryDirectory() as tmp:
transport = SnapshotTransport({
1: view_for(1, view=999),
2: view_for(2, view=222),
})
db, repo, service = make_service(tmp, transport)
try:
add_video(repo, 1)
add_video(repo, 2)
today = DATE
repo.upsert_snapshot(
today, 1,
metrics={"view": 111, "like": 1, "coin": 0, "favorite": 0,
"share": 0, "reply": 0, "danmaku": 0},
ok=True, err=None, captured_at=clock, src="view",
)
repo.upsert_snapshot(
today, 2,
metrics={"view": 20, "like": 1, "coin": 0, "favorite": 0,
"share": 0, "reply": 0, "danmaku": 0},
ok=True, err=None, captured_at=clock, src="seed",
)
repo.kv_set(service.seeded_key(today), "1")
sealed = service.run_if_needed(date=today, force=True)
assert sealed.completed is True
assert transport.count("/web-interface/view") == 1
assert int(repo.get_snapshot(today, 1)["view"]) == 111
finally:
db.close()
def main():
test_snapshot_success_resume_and_force()
test_resume_skips_ok_and_retries_failed()
@@ -724,6 +791,8 @@ def main():
test_seed_from_last_completed_does_not_mark_completed()
test_seeded_day_skips_without_force_and_seals_with_force()
test_seal_keep_scraped_skips_view_progress_punches_seed()
test_force_keep_scraped_false_rescrapes_view_progress()
test_force_default_keep_scraped_skips_view_progress()
print("OK: 每日快照、断点续跑、失败占位与粉丝采集测试全部通过")
+132
View File
@@ -20,6 +20,7 @@ from reaction_board.task_state import ( # noqa: E402
STATUS_FAILED,
STATUS_PENDING,
STATUS_RUNNING,
bootstrap_from_last,
ensure_pending,
load_state,
mark_deferred,
@@ -251,6 +252,133 @@ def test_fail_or_defer_preserves_progress():
db.close()
def test_bootstrap_from_last_fail_due_soon():
"""A: 仅有 task:last 失败、无 state → 回填 failed 且 due_at 可立刻补跑。"""
with tempfile.TemporaryDirectory() as tmp:
db, repo = _repo(tmp)
try:
now = datetime(2026, 11, 3, 12, 0, tzinfo=SHANGHAI_TZ).timestamp()
wall = next_daily_run(now, "23:15")
repo.kv_set(
"task:last:snapshot",
json.dumps({
"name": "snapshot",
"ok": False,
"skipped": False,
"started_at": int(now - 600),
"finished_at": int(now - 500),
"error": "boom",
"summary": {"stat_date": "2026-11-03"},
}, ensure_ascii=False),
)
assert load_state(repo, "snapshot") is None
boot = bootstrap_from_last(
repo, "snapshot", wall_due_at=wall, now=now, retry_seconds=300.0,
)
assert boot is not None
assert boot.status == STATUS_FAILED
assert boot.stat_date == "2026-11-03"
assert boot.due_at <= now
nxt = startup_next_run(
repo, "snapshot", wall_due_at=wall, now=now, slot_hhmm="23:15",
)
assert nxt <= now
assert nxt != wall
finally:
db.close()
def test_ensure_pending_does_not_glue_yesterday_stat_date():
"""B: 昨天未完成保留;不得把今晚 due 粘到昨天的 stat_date 上。"""
with tempfile.TemporaryDirectory() as tmp:
db, repo = _repo(tmp)
try:
now = datetime(2026, 11, 3, 0, 10, tzinfo=SHANGHAI_TZ).timestamp()
yday_due = now - 60
ensure_pending(
repo, "snapshot", due_at=yday_due,
stat_date="2026-11-02", now=now - 120,
)
tonight = datetime(2026, 11, 3, 23, 15, tzinfo=SHANGHAI_TZ).timestamp()
# seed 想挂今晚:不得冲掉昨天未完成
kept = ensure_pending(
repo, "snapshot", due_at=tonight,
stat_date="2026-11-03", now=now,
)
assert kept.stat_date == "2026-11-02"
assert kept.due_at == yday_due
assert resume_stat_date(
repo, "snapshot", fallback="2026-11-03",
) == "2026-11-02"
finally:
db.close()
def test_cross_midnight_mid_seal_resumes_yesterday():
"""规则1 / B: 23:15 崩溃 → 00:10 重启续跑昨天,due 立刻可跑。"""
with tempfile.TemporaryDirectory() as tmp:
db, repo = _repo(tmp)
try:
crash = datetime(2026, 11, 2, 23, 40, tzinfo=SHANGHAI_TZ).timestamp()
mark_running(
repo, "snapshot", stat_date="2026-11-02", now=crash,
progress={"done": 3, "total": 10},
)
repo.kv_set("snapshot:seeded:2026-11-02", "1")
restart = datetime(2026, 11, 3, 0, 10, tzinfo=SHANGHAI_TZ).timestamp()
wall = next_daily_run(restart, "23:15")
nxt = startup_next_run(
repo, "snapshot", wall_due_at=wall, now=restart, slot_hhmm="23:15",
)
assert nxt <= restart
assert resume_stat_date(
repo, "snapshot", fallback="2026-11-03",
) == "2026-11-02"
state = load_state(repo, "snapshot")
assert state is not None
assert state.status == STATUS_PENDING
assert state.progress == {"done": 3, "total": 10}
finally:
db.close()
def test_scheduler_due_reader_picks_up_enqueued_seal():
"""D: 同进程 seed 写盘 due_at=now 后,下一轮 run_due 须跑 seal。"""
with tempfile.TemporaryDirectory() as tmp:
db, repo = _repo(tmp)
try:
now = datetime(2026, 11, 3, 23, 20, tzinfo=SHANGHAI_TZ).timestamp()
# snapshot 原 next_run 挂次日;盘上被 enqueue 成 now
tomorrow = next_daily_run(now, "23:15")
calls = []
def run_snap():
calls.append("snap")
return None
def due_reader(name: str):
st = load_state(repo, name)
return float(st.due_at) if st and st.due_at else None
clock = [now]
sched = Scheduler(
[ScheduledTask("snapshot", 86400.0, run_snap, next_run=tomorrow)],
clock=lambda: clock[0],
due_reader=due_reader,
)
assert sched.run_due() == []
assert calls == []
ensure_pending(
repo, "snapshot", due_at=now, stat_date="2026-11-03", now=now,
)
results = sched.run_due()
assert len(results) == 1
assert calls == ["snap"]
finally:
db.close()
def main_test():
test_restart_mid_run_resumes_same_stat_date()
test_seeded_before_snapshot_at_does_not_force_run()
@@ -259,6 +387,10 @@ def main_test():
test_done_writes_next_due_and_blocks_until_then()
test_no_state_falls_back_to_wall_clock()
test_fail_or_defer_preserves_progress()
test_bootstrap_from_last_fail_due_soon()
test_ensure_pending_does_not_glue_yesterday_stat_date()
test_cross_midnight_mid_seal_resumes_yesterday()
test_scheduler_due_reader_picks_up_enqueued_seal()
print("OK: task_state persistence / resume / due_at gates")