test(scheduler): nail H7 progress keep + H8 deferred task:state
Address Michał NEED-CHANGES on !7 tip f408da93: fail/defer omit-progress
preserves progress/stat_date; seed + quiet-window defer assert
status=deferred and future due_at on task:state. Soft: seeded due≠wall;
cleanup failure reads task:state.
This commit is contained in:
1 parent
f408da93c2
commit
dcf37c4a1d
2 files changed
+124
-5
No files matched your search
+68
-2
@@ -452,8 +452,9 @@ def test_snapshot_seed_no_source_is_non_success():
|
||||
|
||||
|
||||
def test_defer_snapshot_seed_schedules_ten_minutes():
|
||||
"""调度辅助:skip 记非成功,next_run = finished + 600。"""
|
||||
"""调度辅助:skip 记非成功,next_run = finished + 600;落盘 task:state=deferred。"""
|
||||
from reaction_board.logging_json import JsonLogger
|
||||
from reaction_board.task_state import STATUS_DEFERRED, load_state
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = write_config(tmp, cookie="", csrf="")
|
||||
@@ -472,9 +473,64 @@ def test_defer_snapshot_seed_schedules_ten_minutes():
|
||||
)
|
||||
assert ctrl.ok is False
|
||||
assert ctrl.error == "snapshot_running"
|
||||
assert abs(ctrl.next_run - (finished + cli._SEED_RETRY_SECONDS)) < 0.01
|
||||
expected_due = finished + cli._SEED_RETRY_SECONDS
|
||||
assert abs(ctrl.next_run - expected_due) < 0.01
|
||||
last = json.loads(repo.kv_get("task:last:snapshot_seed"))
|
||||
assert last["skipped"] is True and last["ok"] is False
|
||||
state = load_state(repo, "snapshot_seed")
|
||||
assert state is not None
|
||||
assert state.status == STATUS_DEFERRED
|
||||
assert abs(state.due_at - expected_due) < 0.01
|
||||
assert state.due_at > finished
|
||||
assert state.error == "snapshot_running"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_quiet_window_defer_persists_deferred_state():
|
||||
"""H8: 静默窗 defer 落盘 status=deferred + due_at=窗后 01:00,不得记成功。"""
|
||||
from datetime import datetime
|
||||
from unittest import mock
|
||||
|
||||
from reaction_board.logging_json import JsonLogger
|
||||
from reaction_board.task_state import STATUS_DEFERRED, load_state
|
||||
from reaction_board.timeutil import (
|
||||
SHANGHAI_TZ,
|
||||
next_after_snapshot_quiet_window,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = write_config(tmp, cookie="", csrf="")
|
||||
code, _ = run_cli(["init", "--config", str(path)])
|
||||
assert code == 0
|
||||
db = Database(Path(tmp) / "app.db")
|
||||
db.initialize()
|
||||
try:
|
||||
repo = Repository(db)
|
||||
logger = JsonLogger()
|
||||
# 静默窗内:2026-11-03 23:30 上海
|
||||
quiet = datetime(2026, 11, 3, 23, 30, tzinfo=SHANGHAI_TZ).timestamp()
|
||||
expected_due = next_after_snapshot_quiet_window(quiet)
|
||||
assert expected_due > quiet
|
||||
with mock.patch("reaction_board.cli.time.time", return_value=quiet):
|
||||
ctrl = cli._quiet_window_defer(
|
||||
repo, logger, "discovery", started_at=int(quiet) - 1,
|
||||
)
|
||||
assert ctrl.ok is False
|
||||
assert ctrl.error == cli._QUIET_SKIP_REASON
|
||||
assert abs(ctrl.next_run - expected_due) < 0.01
|
||||
last = json.loads(repo.kv_get("task:last:discovery"))
|
||||
assert last["ok"] is False and last["skipped"] is True
|
||||
assert last["error"] == cli._QUIET_SKIP_REASON
|
||||
# 不得推成成功完成类 kv
|
||||
assert repo.kv_get("snapshot:last_completed") is None
|
||||
assert repo.kv_get("discovery:last_completed") is None
|
||||
state = load_state(repo, "discovery")
|
||||
assert state is not None
|
||||
assert state.status == STATUS_DEFERRED
|
||||
assert abs(state.due_at - expected_due) < 0.01
|
||||
assert state.due_at > quiet
|
||||
assert state.error == cli._QUIET_SKIP_REASON
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -571,6 +627,8 @@ def test_candidate_cleanup_failure_retries_soon():
|
||||
Repository.delete_expired_candidates = original_delete
|
||||
|
||||
assert code == 0, output
|
||||
from reaction_board.task_state import STATUS_FAILED, load_state
|
||||
|
||||
db = Database(Path(tmp) / "app.db")
|
||||
db.initialize()
|
||||
try:
|
||||
@@ -578,6 +636,13 @@ def test_candidate_cleanup_failure_retries_soon():
|
||||
last = json.loads(repo.kv_get("task:last:candidate_cleanup"))
|
||||
assert last["ok"] is False
|
||||
assert "cleanup db boom" in last["error"]
|
||||
state = load_state(repo, "candidate_cleanup")
|
||||
assert state is not None
|
||||
assert state.status == STATUS_FAILED
|
||||
assert state.due_at > 0
|
||||
delta_state = state.due_at - __import__("time").time()
|
||||
assert 250 <= delta_state <= 360, delta_state
|
||||
assert "cleanup db boom" in (state.error or "")
|
||||
finally:
|
||||
db.close()
|
||||
scheduler = captured["scheduler"]
|
||||
@@ -759,6 +824,7 @@ def main_test():
|
||||
test_snapshot_seed_no_source_is_non_success()
|
||||
test_snapshot_seed_same_day_completed_succeeds()
|
||||
test_defer_snapshot_seed_schedules_ten_minutes()
|
||||
test_quiet_window_defer_persists_deferred_state()
|
||||
test_candidate_cleanup_failure_retries_soon()
|
||||
test_initial_daily_next_run_uses_wall_clock_not_zero()
|
||||
test_reached_daily_slot_gates_force_seed()
|
||||
|
||||
@@ -15,12 +15,14 @@ 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,
|
||||
ensure_pending,
|
||||
load_state,
|
||||
mark_deferred,
|
||||
mark_done,
|
||||
mark_failed,
|
||||
mark_running,
|
||||
@@ -65,22 +67,26 @@ def test_restart_mid_run_resumes_same_stat_date():
|
||||
|
||||
|
||||
def test_seeded_before_snapshot_at_does_not_force_run():
|
||||
"""seed 后挂 pending due_at=当晚;白天重启 next_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()
|
||||
slot = datetime(2026, 11, 3, 23, 15, 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=next_daily_run(now, "23:15"),
|
||||
wall_due_at=wall,
|
||||
now=now,
|
||||
)
|
||||
assert nxt == slot
|
||||
assert nxt != wall
|
||||
assert nxt > now
|
||||
calls = []
|
||||
|
||||
@@ -199,6 +205,52 @@ def test_no_state_falls_back_to_wall_clock():
|
||||
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 main_test():
|
||||
test_restart_mid_run_resumes_same_stat_date()
|
||||
test_seeded_before_snapshot_at_does_not_force_run()
|
||||
@@ -206,6 +258,7 @@ def main_test():
|
||||
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()
|
||||
print("OK: task_state persistence / resume / due_at gates")
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user