fix(scheduler): address !7 REQUEST_CHANGES
- seed: check completed_key before no_source so same-day last_completed is already_completed (not 10m retry) - scheduler: resolve TaskControl ok/error before logging; ok=False logs task_fail, not task_ok - candidate_cleanup: catch failures, record task, retry ~5m like discover
This commit is contained in:
1 parent
bb18d232f6
commit
1697573d95
7 files changed
+264
-17
No files matched your search
@@ -479,6 +479,115 @@ def test_defer_snapshot_seed_schedules_ten_minutes():
|
||||
db.close()
|
||||
|
||||
|
||||
|
||||
|
||||
def test_snapshot_seed_same_day_completed_succeeds():
|
||||
"""当日已完成时手动 snapshot-seed 应成功 already_completed,不走 no_source。"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = write_config(tmp, cookie="", csrf="")
|
||||
code, _ = run_cli(["init", "--config", str(path)])
|
||||
assert code == 0
|
||||
today = "2026-10-31"
|
||||
db = Database(Path(tmp) / "app.db")
|
||||
db.initialize()
|
||||
try:
|
||||
repo = Repository(db)
|
||||
repo.kv_set("snapshot:last_completed", today)
|
||||
repo.kv_set("snapshot:completed:" + today, "1")
|
||||
finally:
|
||||
db.close()
|
||||
code, output = run_cli([
|
||||
"snapshot-seed", "--config", str(path), "--date", today,
|
||||
])
|
||||
assert code == 0, output
|
||||
assert '"reason": "already_completed"' in output
|
||||
assert "no_source" not in output
|
||||
db = Database(Path(tmp) / "app.db")
|
||||
db.initialize()
|
||||
try:
|
||||
repo = Repository(db)
|
||||
last = json.loads(repo.kv_get("task:last:snapshot_seed"))
|
||||
assert last["ok"] is True
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_candidate_cleanup_failure_retries_soon():
|
||||
"""candidate_cleanup 异常应记失败任务并约 5 分钟补跑,而非落到 86400 间隔。"""
|
||||
class FakeClient:
|
||||
def check_login(self):
|
||||
return LoginState(True, 1, "tester", 0, "OK")
|
||||
|
||||
class FakeDiscovery:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
from reaction_board.services.discovery import DiscoverySummary
|
||||
return DiscoverySummary(started_at=1, finished_at=1)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = write_config(tmp)
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
data["folders"] = []
|
||||
data["track_fans"] = []
|
||||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
original_client = cli._client
|
||||
original_discovery = cli.DiscoveryService
|
||||
original_delete = Repository.delete_expired_candidates
|
||||
captured = {}
|
||||
|
||||
def boom(self, *args, **kwargs):
|
||||
raise RuntimeError("cleanup db boom")
|
||||
|
||||
def fake_client(credentials, *args, **kwargs):
|
||||
return FakeClient()
|
||||
|
||||
# 只跑 cleanup:把其它任务 next_run 推远;通过 Scheduler 包装拦截。
|
||||
original_scheduler = cli.Scheduler
|
||||
|
||||
class CaptureScheduler(original_scheduler):
|
||||
def __init__(self, tasks, **kwargs):
|
||||
far = 1_000_000_000_000.0
|
||||
for task in tasks:
|
||||
if task.name != "candidate_cleanup":
|
||||
task.next_run = far
|
||||
else:
|
||||
task.next_run = 0.0
|
||||
super().__init__(tasks, **kwargs)
|
||||
captured["scheduler"] = self
|
||||
|
||||
cli._client = fake_client
|
||||
cli.DiscoveryService = FakeDiscovery
|
||||
cli.Scheduler = CaptureScheduler
|
||||
Repository.delete_expired_candidates = boom
|
||||
try:
|
||||
code, output = run_cli(["run", "--once", "--config", str(path)])
|
||||
finally:
|
||||
cli._client = original_client
|
||||
cli.DiscoveryService = original_discovery
|
||||
cli.Scheduler = original_scheduler
|
||||
Repository.delete_expired_candidates = original_delete
|
||||
|
||||
assert code == 0, output
|
||||
db = Database(Path(tmp) / "app.db")
|
||||
db.initialize()
|
||||
try:
|
||||
repo = Repository(db)
|
||||
last = json.loads(repo.kv_get("task:last:candidate_cleanup"))
|
||||
assert last["ok"] is False
|
||||
assert "cleanup db boom" in last["error"]
|
||||
finally:
|
||||
db.close()
|
||||
scheduler = captured["scheduler"]
|
||||
cleanup_task = next(t for t in scheduler.tasks if t.name == "candidate_cleanup")
|
||||
# next_run ≈ finished + 300;允许几秒时钟误差
|
||||
assert cleanup_task.next_run > 0
|
||||
delta = cleanup_task.next_run - __import__("time").time()
|
||||
assert 250 <= delta <= 360, delta
|
||||
|
||||
|
||||
def main_test():
|
||||
test_init_and_status()
|
||||
test_missing_config()
|
||||
@@ -496,7 +605,9 @@ def main_test():
|
||||
test_snapshot_seed_cli_clones_offline()
|
||||
test_snapshot_seed_defers_when_snapshot_running()
|
||||
test_snapshot_seed_no_source_is_non_success()
|
||||
test_snapshot_seed_same_day_completed_succeeds()
|
||||
test_defer_snapshot_seed_schedules_ten_minutes()
|
||||
test_candidate_cleanup_failure_retries_soon()
|
||||
print("OK: CLI init/sync/snapshot/snapshot-seed/auth-check/run/status/backfill/错误路径测试全部通过")
|
||||
|
||||
|
||||
|
||||
@@ -152,10 +152,87 @@ def test_task_control_overrides_ok_and_next_run():
|
||||
assert task.next_run == 2_000.0
|
||||
|
||||
|
||||
|
||||
|
||||
class _CaptureLogger:
|
||||
def __init__(self):
|
||||
self.events = []
|
||||
|
||||
def info(self, event, **fields):
|
||||
self.events.append(("info", event, fields))
|
||||
|
||||
def warning(self, event, **fields):
|
||||
self.events.append(("warning", event, fields))
|
||||
|
||||
def error(self, event, **fields):
|
||||
self.events.append(("error", event, fields))
|
||||
|
||||
|
||||
def test_task_control_ok_false_logs_fail_not_ok():
|
||||
"""TaskControl(ok=False) 不得记 scheduler.task_ok(Skip ≠ success)。"""
|
||||
clock = [1_000.0]
|
||||
logger = _CaptureLogger()
|
||||
task = ScheduledTask(
|
||||
"quiet",
|
||||
60,
|
||||
lambda: TaskControl(ok=False, next_run=1_500.0, error="snapshot_quiet_window"),
|
||||
)
|
||||
scheduler = Scheduler([task], clock=lambda: clock[0], logger=logger)
|
||||
results = scheduler.run_due()
|
||||
assert results[0].ok is False
|
||||
assert results[0].error == "snapshot_quiet_window"
|
||||
assert ("info", "scheduler.task_ok", {"task": "quiet"}) not in logger.events
|
||||
fail_events = [e for e in logger.events if e[1] == "scheduler.task_fail"]
|
||||
assert len(fail_events) == 1
|
||||
assert fail_events[0][0] == "warning"
|
||||
assert fail_events[0][2]["task"] == "quiet"
|
||||
assert fail_events[0][2]["error"] == "snapshot_quiet_window"
|
||||
|
||||
|
||||
def test_task_control_ok_true_still_logs_task_ok():
|
||||
clock = [1_000.0]
|
||||
logger = _CaptureLogger()
|
||||
task = ScheduledTask(
|
||||
"ok_task",
|
||||
60,
|
||||
lambda: TaskControl(ok=True, next_run=1_200.0),
|
||||
)
|
||||
scheduler = Scheduler([task], clock=lambda: clock[0], logger=logger)
|
||||
results = scheduler.run_due()
|
||||
assert results[0].ok is True
|
||||
assert ("info", "scheduler.task_ok", {"task": "ok_task"}) in logger.events
|
||||
assert not any(e[1] == "scheduler.task_fail" for e in logger.events)
|
||||
|
||||
|
||||
def test_raised_exception_logs_task_fail():
|
||||
clock = [100.0]
|
||||
logger = _CaptureLogger()
|
||||
|
||||
def boom():
|
||||
raise RuntimeError("db down")
|
||||
|
||||
scheduler = Scheduler(
|
||||
[ScheduledTask("bad", 10, boom)],
|
||||
clock=lambda: clock[0],
|
||||
logger=logger,
|
||||
)
|
||||
results = scheduler.run_due()
|
||||
assert results[0].ok is False
|
||||
assert results[0].error == "db down"
|
||||
assert not any(e[1] == "scheduler.task_ok" for e in logger.events)
|
||||
assert not any(e[1] == "scheduler.task_fail" for e in logger.events)
|
||||
err_events = [e for e in logger.events if e[1] == "scheduler.task_error"]
|
||||
assert len(err_events) == 1
|
||||
assert err_events[0][0] == "error"
|
||||
assert err_events[0][2]["error"] == "db down"
|
||||
|
||||
def main():
|
||||
test_run_when_due_and_reschedule()
|
||||
test_next_run_fn_controls_reschedule()
|
||||
test_task_control_overrides_ok_and_next_run()
|
||||
test_task_control_ok_false_logs_fail_not_ok()
|
||||
test_task_control_ok_true_still_logs_task_ok()
|
||||
test_raised_exception_logs_task_fail()
|
||||
test_task_error_does_not_block_other_tasks()
|
||||
test_run_forever_with_stop_event()
|
||||
test_max_iterations_and_validation()
|
||||
|
||||
@@ -537,6 +537,36 @@ def test_full_snapshot_skips_ok_rows_including_hourly_refresh():
|
||||
|
||||
|
||||
|
||||
def test_seed_same_day_completed_is_already_completed_not_no_source():
|
||||
"""全站完成后 last_completed==target:应 already_completed,不误判 no_source。"""
|
||||
clock = 1_800_000_000
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
transport = SnapshotTransport({1: view_for(1)})
|
||||
db, repo, service = make_service(tmp, transport, track_fans=())
|
||||
try:
|
||||
add_video(repo, 1)
|
||||
today = "2026-10-31"
|
||||
repo.upsert_snapshot(
|
||||
today, 1,
|
||||
metrics={"view": 10, "like": 1, "coin": 0, "favorite": 0,
|
||||
"share": 0, "reply": 0, "danmaku": 0},
|
||||
ok=True, err=None, captured_at=clock,
|
||||
)
|
||||
# 正常收尾态:last_completed 已推进到当日,且 completed 标记在位
|
||||
repo.kv_set("snapshot:last_completed", today)
|
||||
repo.kv_set(service.completed_key(today), "1")
|
||||
|
||||
summary = service.run_seed_from_last_completed(date=today)
|
||||
assert summary.reason == "already_completed"
|
||||
assert summary.already_completed is True
|
||||
assert summary.completed is True
|
||||
assert summary.reason != "no_source"
|
||||
assert transport.count("/web-interface/view") == 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
|
||||
def test_seed_from_last_completed_does_not_mark_completed():
|
||||
clock = 1_800_000_000
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -646,6 +676,7 @@ def main():
|
||||
test_backup_failure_retries_without_api()
|
||||
test_run_recent_videos_refreshes_without_completion_markers()
|
||||
test_full_snapshot_skips_ok_rows_including_hourly_refresh()
|
||||
test_seed_same_day_completed_is_already_completed_not_no_source()
|
||||
test_seed_from_last_completed_does_not_mark_completed()
|
||||
test_seeded_day_skips_without_force_and_seals_with_force()
|
||||
print("OK: 每日快照、断点续跑、失败占位与粉丝采集测试全部通过")
|
||||
|
||||
Reference in new issue
Block a user