- 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).
399 lines
14 KiB
Python
399 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""task:state 持久化与启动续跑: uv run python tests/test_task_state.py"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
|
|
from reaction_board.database import Database # noqa: E402
|
|
from reaction_board.repository import Repository # noqa: E402
|
|
from reaction_board.scheduler import ScheduledTask, Scheduler # noqa: E402
|
|
from reaction_board.task_state import ( # noqa: E402
|
|
STATUS_DEFERRED,
|
|
STATUS_DONE,
|
|
STATUS_FAILED,
|
|
STATUS_PENDING,
|
|
STATUS_RUNNING,
|
|
bootstrap_from_last,
|
|
ensure_pending,
|
|
load_state,
|
|
mark_deferred,
|
|
mark_done,
|
|
mark_failed,
|
|
mark_running,
|
|
recover_stale_running,
|
|
resume_stat_date,
|
|
startup_next_run,
|
|
)
|
|
from reaction_board.timeutil import SHANGHAI_TZ, next_daily_run # noqa: E402
|
|
|
|
|
|
def _repo(tmp: str) -> tuple[Database, Repository]:
|
|
db = Database(Path(tmp) / "app.db")
|
|
db.initialize()
|
|
return db, Repository(db)
|
|
|
|
|
|
def test_restart_mid_run_resumes_same_stat_date():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = _repo(tmp)
|
|
try:
|
|
now = datetime(2026, 11, 3, 23, 40, tzinfo=SHANGHAI_TZ).timestamp()
|
|
mark_running(
|
|
repo, "snapshot", stat_date="2026-11-03", now=now,
|
|
progress={"done": 10, "total": 100},
|
|
)
|
|
# 模拟进程崩溃:running 残留
|
|
recovered = recover_stale_running(repo, "snapshot", now=now + 60)
|
|
assert recovered is not None
|
|
assert recovered.status == STATUS_PENDING
|
|
assert recovered.stat_date == "2026-11-03"
|
|
assert recovered.progress == {"done": 10, "total": 100}
|
|
assert recovered.due_at <= now + 60
|
|
assert resume_stat_date(repo, "snapshot", fallback="2026-11-04") == "2026-11-03"
|
|
nxt = startup_next_run(
|
|
repo, "snapshot",
|
|
wall_due_at=next_daily_run(now + 60, "23:15"),
|
|
now=now + 60,
|
|
)
|
|
assert nxt <= now + 60
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_seeded_before_snapshot_at_does_not_force_run():
|
|
"""seed 后挂 pending;白天重启认落盘 due_at(刻意 ≠ 墙钟)且未到不跑。"""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = _repo(tmp)
|
|
try:
|
|
now = datetime(2026, 11, 3, 10, 30, tzinfo=SHANGHAI_TZ).timestamp()
|
|
# 刻意偏离墙钟 23:15,证明认落盘而非 fallback
|
|
slot = datetime(2026, 11, 3, 22, 0, tzinfo=SHANGHAI_TZ).timestamp()
|
|
wall = next_daily_run(now, "23:15")
|
|
assert slot != wall
|
|
ensure_pending(
|
|
repo, "snapshot", due_at=slot, stat_date="2026-11-03", now=now,
|
|
)
|
|
repo.kv_set("snapshot:seeded:2026-11-03", "1")
|
|
nxt = startup_next_run(
|
|
repo, "snapshot",
|
|
wall_due_at=wall,
|
|
now=now,
|
|
)
|
|
assert nxt == slot
|
|
assert nxt != wall
|
|
assert nxt > now
|
|
calls = []
|
|
|
|
def run_snapshot():
|
|
calls.append("ran")
|
|
return None
|
|
|
|
clock = [now]
|
|
sched = Scheduler(
|
|
[ScheduledTask("snapshot", 86400.0, run_snapshot, next_run=nxt)],
|
|
clock=lambda: clock[0],
|
|
)
|
|
assert sched.run_due() == []
|
|
assert calls == []
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_failed_future_due_at_does_not_run_early():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = _repo(tmp)
|
|
try:
|
|
now = datetime(2026, 11, 3, 12, 0, tzinfo=SHANGHAI_TZ).timestamp()
|
|
due = now + 600
|
|
mark_failed(
|
|
repo, "snapshot", due_at=due, now=now,
|
|
stat_date="2026-11-03", error="boom",
|
|
)
|
|
nxt = startup_next_run(
|
|
repo, "snapshot",
|
|
wall_due_at=next_daily_run(now, "23:15"),
|
|
now=now,
|
|
)
|
|
assert nxt == due
|
|
clock = [now]
|
|
calls = []
|
|
sched = Scheduler(
|
|
[
|
|
ScheduledTask(
|
|
"snapshot", 86400.0, lambda: calls.append(1), next_run=nxt,
|
|
)
|
|
],
|
|
clock=lambda: clock[0],
|
|
)
|
|
assert sched.run_due() == []
|
|
assert calls == []
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_due_at_passed_runs_including_failed_retry():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = _repo(tmp)
|
|
try:
|
|
now = datetime(2026, 11, 3, 12, 10, tzinfo=SHANGHAI_TZ).timestamp()
|
|
due = now - 30 # 已过重试点
|
|
mark_failed(
|
|
repo, "snapshot", due_at=due, now=due - 300,
|
|
stat_date="2026-11-03", error="boom",
|
|
)
|
|
nxt = startup_next_run(
|
|
repo, "snapshot",
|
|
wall_due_at=next_daily_run(now, "23:15"),
|
|
now=now,
|
|
)
|
|
# 不得退回今晚墙钟;应立刻可跑
|
|
assert nxt == due
|
|
assert nxt <= now
|
|
wall = next_daily_run(now, "23:15")
|
|
assert nxt != wall
|
|
calls = []
|
|
clock = [now]
|
|
sched = Scheduler(
|
|
[
|
|
ScheduledTask(
|
|
"snapshot", 86400.0, lambda: calls.append(1), next_run=nxt,
|
|
)
|
|
],
|
|
clock=lambda: clock[0],
|
|
)
|
|
results = sched.run_due()
|
|
assert len(results) == 1
|
|
assert calls == [1]
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_done_writes_next_due_and_blocks_until_then():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = _repo(tmp)
|
|
try:
|
|
now = datetime(2026, 11, 3, 23, 40, tzinfo=SHANGHAI_TZ).timestamp()
|
|
next_due = next_daily_run(now, "23:15")
|
|
mark_done(repo, "snapshot", due_at=next_due, now=now, stat_date="2026-11-03")
|
|
state = load_state(repo, "snapshot")
|
|
assert state is not None
|
|
assert state.status == STATUS_DONE
|
|
nxt = startup_next_run(
|
|
repo, "snapshot", wall_due_at=next_due, now=now + 60,
|
|
)
|
|
assert nxt == next_due
|
|
assert nxt > now + 60
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_no_state_falls_back_to_wall_clock():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = _repo(tmp)
|
|
try:
|
|
now = datetime(2026, 11, 3, 10, 0, tzinfo=SHANGHAI_TZ).timestamp()
|
|
wall = next_daily_run(now, "23:15")
|
|
nxt = startup_next_run(repo, "snapshot", wall_due_at=wall, now=now)
|
|
assert nxt == wall
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
|
|
def test_fail_or_defer_preserves_progress():
|
|
"""H7: mark_failed / mark_deferred 不传 progress 时保留已有 progress 与 stat_date。"""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = _repo(tmp)
|
|
try:
|
|
now = datetime(2026, 11, 3, 12, 0, tzinfo=SHANGHAI_TZ).timestamp()
|
|
progress = {"done": 42, "total": 100, "cursor": "aid:99"}
|
|
mark_running(
|
|
repo, "snapshot", stat_date="2026-11-03", now=now,
|
|
progress=progress,
|
|
)
|
|
before = load_state(repo, "snapshot")
|
|
assert before is not None
|
|
assert before.progress == progress
|
|
assert before.stat_date == "2026-11-03"
|
|
|
|
due_fail = now + 300
|
|
mark_failed(
|
|
repo, "snapshot", due_at=due_fail, now=now + 1,
|
|
progress=None, error="boom",
|
|
)
|
|
failed = load_state(repo, "snapshot")
|
|
assert failed is not None
|
|
assert failed.status == STATUS_FAILED
|
|
assert failed.due_at == due_fail
|
|
assert failed.progress == progress
|
|
assert failed.stat_date == "2026-11-03"
|
|
assert failed.error == "boom"
|
|
|
|
due_defer = now + 600
|
|
mark_deferred(
|
|
repo, "snapshot", due_at=due_defer, now=now + 2,
|
|
progress=None, error="snapshot_quiet_window",
|
|
)
|
|
deferred = load_state(repo, "snapshot")
|
|
assert deferred is not None
|
|
assert deferred.status == STATUS_DEFERRED
|
|
assert deferred.due_at == due_defer
|
|
assert deferred.progress == progress
|
|
assert deferred.stat_date == "2026-11-03"
|
|
assert deferred.error == "snapshot_quiet_window"
|
|
finally:
|
|
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()
|
|
test_failed_future_due_at_does_not_run_early()
|
|
test_due_at_passed_runs_including_failed_retry()
|
|
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")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main_test()
|