feat(scheduler): add snapshot-seed CLI and 10m seed deferral
Add `python app.py snapshot-seed [--date]` (offline clone, Debian / docker compose exec friendly). When seed fires while full-site snapshot is running, or last_completed is missing (no_source), record skip as non-success and retry in 10 minutes until a real seed succeeds; fans and quiet-window rules unchanged.
This commit is contained in:
1 parent
9969e65202
commit
bb18d232f6
7 files changed
+374
-8
No files matched your search
+153
-1
@@ -332,6 +332,153 @@ def test_backfill_followers_dry_run():
|
||||
assert '"total": 0' in output
|
||||
|
||||
|
||||
|
||||
def test_help_lists_snapshot_seed():
|
||||
from reaction_board.cli import _build_parser
|
||||
|
||||
parser = _build_parser()
|
||||
help_text = parser.format_help()
|
||||
assert "snapshot-seed" in help_text
|
||||
args = parser.parse_args(["snapshot-seed", "--date", "2026-10-31"])
|
||||
assert args.command == "snapshot-seed"
|
||||
assert args.date == "2026-10-31"
|
||||
|
||||
|
||||
def test_snapshot_seed_cli_clones_offline():
|
||||
from reaction_board.bilibili.models import FavResource
|
||||
|
||||
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)
|
||||
media_id = 11
|
||||
repo.upsert_from_fav(
|
||||
FavResource(
|
||||
aid=1, bvid="BV1xx411c7mD", title="t", up_mid=9,
|
||||
up_name="u", pubdate=1, duration=10, cover=None,
|
||||
attr=0, fav_time=None,
|
||||
),
|
||||
media_id,
|
||||
was_added=True,
|
||||
)
|
||||
yday, today = "2026-10-30", "2026-10-31"
|
||||
repo.upsert_snapshot(
|
||||
yday, 1,
|
||||
metrics={"view": 10, "like": 1, "coin": 0, "favorite": 0,
|
||||
"share": 0, "reply": 0, "danmaku": 0},
|
||||
ok=True, err=None, captured_at=1_800_000_000,
|
||||
)
|
||||
repo.kv_set("snapshot:last_completed", yday)
|
||||
repo.kv_set("snapshot:completed:" + yday, "1")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
code, output = run_cli([
|
||||
"snapshot-seed", "--config", str(path), "--date", today,
|
||||
])
|
||||
assert code == 0, output
|
||||
assert '"reason": "seeded"' in output
|
||||
db = Database(Path(tmp) / "app.db")
|
||||
db.initialize()
|
||||
try:
|
||||
repo = Repository(db)
|
||||
assert repo.kv_get("snapshot:seeded:" + today) == "1"
|
||||
row = repo.get_snapshot(today, 1)
|
||||
assert row is not None and int(row["view"]) == 10
|
||||
assert row["src"] == "seed"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_snapshot_seed_defers_when_snapshot_running():
|
||||
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)
|
||||
repo.kv_set(
|
||||
"task:running:snapshot",
|
||||
json.dumps({"name": "snapshot", "started_at": 1}),
|
||||
)
|
||||
repo.kv_set("snapshot:last_completed", "2026-10-30")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
code, output = run_cli([
|
||||
"snapshot-seed", "--config", str(path), "--date", "2026-10-31",
|
||||
])
|
||||
assert code == 1, output
|
||||
assert "snapshot_running" 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 False
|
||||
assert last["skipped"] is True
|
||||
assert last["error"] == "snapshot_running"
|
||||
assert repo.kv_get("task:running:snapshot_seed") is None
|
||||
assert repo.kv_get("task:running:snapshot") is not None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_snapshot_seed_no_source_is_non_success():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = write_config(tmp, cookie="", csrf="")
|
||||
code, _ = run_cli(["init", "--config", str(path)])
|
||||
assert code == 0
|
||||
code, output = run_cli([
|
||||
"snapshot-seed", "--config", str(path), "--date", "2026-10-31",
|
||||
])
|
||||
assert code == 1, output
|
||||
assert "no_source" 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 False and last["skipped"] is True
|
||||
assert last["error"] == "no_source"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_defer_snapshot_seed_schedules_ten_minutes():
|
||||
"""调度辅助:skip 记非成功,next_run = finished + 600。"""
|
||||
from reaction_board.logging_json import JsonLogger
|
||||
|
||||
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()
|
||||
started = 1_800_000_000
|
||||
finished = started + 5
|
||||
ctrl = cli._defer_snapshot_seed(
|
||||
repo, logger, started_at=started, reason="snapshot_running",
|
||||
finished_at=finished,
|
||||
)
|
||||
assert ctrl.ok is False
|
||||
assert ctrl.error == "snapshot_running"
|
||||
assert abs(ctrl.next_run - (finished + cli._SEED_RETRY_SECONDS)) < 0.01
|
||||
last = json.loads(repo.kv_get("task:last:snapshot_seed"))
|
||||
assert last["skipped"] is True and last["ok"] is False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main_test():
|
||||
test_init_and_status()
|
||||
test_missing_config()
|
||||
@@ -345,7 +492,12 @@ def main_test():
|
||||
test_snapshot_backup_failure_returns_error_and_retries()
|
||||
test_candidates_and_discover_commands()
|
||||
test_backfill_followers_dry_run()
|
||||
print("OK: CLI init/sync/snapshot/auth-check/run/status/backfill/错误路径测试全部通过")
|
||||
test_help_lists_snapshot_seed()
|
||||
test_snapshot_seed_cli_clones_offline()
|
||||
test_snapshot_seed_defers_when_snapshot_running()
|
||||
test_snapshot_seed_no_source_is_non_success()
|
||||
test_defer_snapshot_seed_schedules_ten_minutes()
|
||||
print("OK: CLI init/sync/snapshot/snapshot-seed/auth-check/run/status/backfill/错误路径测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -711,6 +711,51 @@ def test_snapshot_failed_next_run_retries_soon_not_next_day():
|
||||
db.close()
|
||||
|
||||
|
||||
def test_snapshot_seed_skip_next_run_retries_ten_minutes():
|
||||
import json
|
||||
from datetime import datetime
|
||||
from reaction_board.timeutil import SHANGHAI_TZ, next_daily_run
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, q = setup(tmp)
|
||||
try:
|
||||
repo = Repository(db)
|
||||
finished = int(datetime(2026, 11, 1, 0, 0, 0, tzinfo=SHANGHAI_TZ).timestamp())
|
||||
repo.kv_set(
|
||||
"task:last:snapshot_seed",
|
||||
json.dumps({
|
||||
"name": "snapshot_seed", "ok": False, "skipped": True,
|
||||
"started_at": finished - 1, "finished_at": finished,
|
||||
"error": "snapshot_running",
|
||||
"summary": {"skipped": True, "reason": "snapshot_running"},
|
||||
}),
|
||||
)
|
||||
DASHBOARD_CACHE.clear()
|
||||
system = q.system()
|
||||
assert system["next_runs"]["snapshot_seed"] == finished + 600
|
||||
assert system["next_runs"]["snapshot_seed"] != int(
|
||||
next_daily_run(finished, "00:00")
|
||||
)
|
||||
# skip 不算 errors
|
||||
assert not any(e["task"] == "snapshot_seed" for e in system["errors"])
|
||||
|
||||
# no_source 同样 10 分钟
|
||||
repo.kv_set(
|
||||
"task:last:snapshot_seed",
|
||||
json.dumps({
|
||||
"name": "snapshot_seed", "ok": False, "skipped": True,
|
||||
"started_at": finished - 1, "finished_at": finished,
|
||||
"error": "no_source",
|
||||
"summary": {"skipped": True, "reason": "no_source"},
|
||||
}),
|
||||
)
|
||||
DASHBOARD_CACHE.clear()
|
||||
system = q.system()
|
||||
assert system["next_runs"]["snapshot_seed"] == finished + 600
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
|
||||
def test_overview_adjacent_previous_with_seed_and_dual_fields():
|
||||
"""seed 当日行存在时,播放增量按片相邻成功日;overview 暴露更新时刻与总数。"""
|
||||
@@ -964,6 +1009,7 @@ def main_test():
|
||||
test_snapshot_recent_next_run_follows_completion()
|
||||
test_snapshot_recent_next_run_defers_quiet_window_and_retry_on_skip()
|
||||
test_snapshot_failed_next_run_retries_soon_not_next_day()
|
||||
test_snapshot_seed_skip_next_run_retries_ten_minutes()
|
||||
test_overview_adjacent_previous_with_seed_and_dual_fields()
|
||||
test_same_day_growth_baselines()
|
||||
test_growth_rate_sort_uses_prior()
|
||||
|
||||
Reference in new issue
Block a user