diff --git a/scripts/diagnose_tournament_freeze.py b/scripts/diagnose_tournament_freeze.py new file mode 100644 index 0000000..8ef5fbd --- /dev/null +++ b/scripts/diagnose_tournament_freeze.py @@ -0,0 +1,252 @@ +"""Diagnose why a tournament bracket is "stuck" on the website. + +The website reads tss_tournaments.db live, so a bracket that never updates means +the *scanner* stopped writing fresh rows for that tournament. This script proves +where the freeze is by comparing, for each tournament: + + - what is STORED in tss_tournaments.db (and how stale it is), + - whether needs_scan() will ever re-scan it (and why not), + - what a FRESH live scan from the TSS API would produce right now, + - the diff (new battles / changed match statuses the DB is missing). + +If the live scan has battles/matches the DB lacks AND needs_scan() is False, the +freeze is in the scanner (a prematurely-stored "finished" status). If the live +scan matches the DB, the data source is current and the staleness is downstream +(web cache / frontend not refreshing). + +Usage: + # Auto-detect frozen tournaments: games arrived after the last scan. + python TSSBOT/scripts/diagnose_tournament_freeze.py + + # Diagnose specific tournament ids. + python TSSBOT/scripts/diagnose_tournament_freeze.py 25003 25034 + + # Tune how many auto-detected tournaments to deep-scan (default 5). + python TSSBOT/scripts/diagnose_tournament_freeze.py --top 10 +""" +import argparse +import asyncio +import pathlib +import sqlite3 +import sys +import time +from typing import Dict, List, Optional, Tuple + +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT.parent / "SHARED")) + +# Match start_bot.py/tss_ws.py: load TSSBOT/.env before importing storage, which +# resolves STORAGE_VOL_PATH at import time. +try: + from dotenv import load_dotenv + load_dotenv(dotenv_path=ROOT / ".env") +except Exception: + pass + +from BOT.storage import TSS_BATTLES_DB_PATH # noqa: E402 +from BOT.tss_tournaments import ( # noqa: E402 + TSS_TOURNAMENTS_DB_PATH, + RESCAN_INTERVAL_SECONDS, + RESCAN_END_BUFFER_SECONDS, + build_scan_sync, + needs_scan, +) + + +def _age(unix: Optional[int], now: int) -> str: + if not unix: + return "never" + secs = max(0, now - int(unix)) + if secs < 90: + return f"{secs}s ago" + if secs < 5400: + return f"{secs // 60}m ago" + if secs < 172800: + return f"{secs // 3600}h ago" + return f"{secs // 86400}d ago" + + +def stored_state(tid: int) -> Optional[Dict]: + """Stored tournaments row + child-row counts, or None if never scanned.""" + if not TSS_TOURNAMENTS_DB_PATH.exists(): + return None + with sqlite3.connect(f"file:{TSS_TOURNAMENTS_DB_PATH}?mode=ro", uri=True) as conn: + row = conn.execute( + "SELECT name, status, date_end, scanned_unix, match_count " + "FROM tournaments WHERE tournament_id = ?", + (tid,), + ).fetchone() + if row is None: + return None + status_counts = dict( + conn.execute( + "SELECT status, COUNT(*) FROM tournament_matches " + "WHERE tournament_id = ? GROUP BY status", + (tid,), + ).fetchall() + ) + battle_count = conn.execute( + "SELECT COUNT(*) FROM tournament_battles WHERE tournament_id = ?", + (tid,), + ).fetchone()[0] + battle_hexes = { + r[0] + for r in conn.execute( + "SELECT session_hex FROM tournament_battles WHERE tournament_id = ?", + (tid,), + ).fetchall() + } + return { + "name": row[0], + "status": row[1], + "date_end": row[2], + "scanned_unix": row[3], + "match_count": row[4], + "status_counts": status_counts, + "battle_count": battle_count, + "battle_hexes": battle_hexes, + } + + +def newest_game_unix(tid: int) -> Optional[int]: + """endtime_unix of the most recent game we ingested for this tournament.""" + with sqlite3.connect(f"file:{TSS_BATTLES_DB_PATH}?mode=ro", uri=True) as conn: + row = conn.execute( + "SELECT MAX(endtime_unix) FROM match_summary WHERE tournament_id = ?", + (tid,), + ).fetchone() + return int(row[0]) if row and row[0] else None + + +def frozen_candidates() -> List[Tuple[int, Optional[str], int, Optional[int]]]: + """Tournaments with a game ingested AFTER their last scan — the freeze tell. + + Returns (tid, name, newest_game_unix, scanned_unix) sorted by newest game. + """ + games = {} + names = {} + with sqlite3.connect(f"file:{TSS_BATTLES_DB_PATH}?mode=ro", uri=True) as conn: + for tid, newest, name in conn.execute( + "SELECT tournament_id, MAX(endtime_unix), NULLIF(MAX(tournament_name), '') " + "FROM match_summary WHERE tournament_id IS NOT NULL AND tournament_id > 0 " + "GROUP BY tournament_id" + ).fetchall(): + games[int(tid)] = int(newest) if newest else 0 + names[int(tid)] = name + + scanned: Dict[int, int] = {} + if TSS_TOURNAMENTS_DB_PATH.exists(): + with sqlite3.connect(f"file:{TSS_TOURNAMENTS_DB_PATH}?mode=ro", uri=True) as conn: + for tid, sunix in conn.execute( + "SELECT tournament_id, scanned_unix FROM tournaments" + ).fetchall(): + scanned[int(tid)] = int(sunix) if sunix else 0 + + out = [] + for tid, newest in games.items(): + sunix = scanned.get(tid, 0) + # A game landed after the last scan → the bracket on the site is stale. + if newest and newest > sunix: + out.append((tid, names.get(tid), newest, scanned.get(tid))) + out.sort(key=lambda r: r[2], reverse=True) + return out + + +async def diagnose(tid: int, name: Optional[str], now: int) -> None: + print(f"\n{'=' * 72}\nTournament {tid}" + (f" ({name})" if name else "")) + print("=" * 72) + + stored = stored_state(tid) + newest_game = newest_game_unix(tid) + + if stored is None: + print(" STORED: never scanned (no row in tss_tournaments.db)") + else: + print(f" STORED: status={stored['status']!r} " + f"last scan {_age(stored['scanned_unix'], now)} " + f"matches={stored['match_count']} battles={stored['battle_count']}") + print(f" match statuses: {stored['status_counts']}") + print(f" date_end {_age(stored['date_end'], now)}") + print(f" NEWEST INGESTED GAME: {_age(newest_game, now)}") + if stored and newest_game and stored["scanned_unix"]: + if newest_game > stored["scanned_unix"]: + print(" >> A game arrived AFTER the last scan — the stored bracket is stale.") + + will_rescan = await needs_scan(tid, now=now) + reason = "" + if stored is None: + reason = "never scanned" + elif stored["status"] == "finished": + reason = "status=='finished' → needs_scan() is permanently False (FROZEN)" + elif stored["date_end"] and now > stored["date_end"] + RESCAN_END_BUFFER_SECONDS: + reason = "past date_end + buffer → re-scan disabled" + else: + nxt = (stored["scanned_unix"] or 0) + RESCAN_INTERVAL_SECONDS + reason = f"interval-gated; next eligible {_age(nxt, now)} ({'due' if nxt <= now else 'waiting'})" + print(f" needs_scan(): {will_rescan} — {reason}") + + print(" LIVE SCAN (fetching from TSS API, not stored)…", flush=True) + try: + scan = build_scan_sync(tid, fallback_name=name, now=now) + except Exception as exc: + print(f" LIVE SCAN FAILED: {exc}") + return + + live_hexes = {b["session_hex"] for b in scan["battles"]} + live_status_counts: Dict[str, int] = {} + for m in scan["matches"]: + live_status_counts[m["status"]] = live_status_counts.get(m["status"], 0) + 1 + print(f" LIVE: status={scan['status']!r} matches={scan['match_count']} " + f"battles={len(scan['battles'])}") + print(f" match statuses: {live_status_counts}") + + stored_hexes = stored["battle_hexes"] if stored else set() + new_battles = live_hexes - stored_hexes + print(f" DIFF: live has {len(new_battles)} battle(s) the DB is missing; " + f"match_count {stored['match_count'] if stored else 0} → {scan['match_count']}") + + if new_battles and not will_rescan: + print(" VERDICT: FROZEN SCANNER — live data is newer but needs_scan() is " + "False, so the bracket will never update. Root cause is the scanner.") + elif new_battles and will_rescan: + print(" VERDICT: due to re-scan — data source is fine; staleness is timing " + "or downstream (web cache / frontend not refreshing).") + elif not new_battles: + print(" VERDICT: DB already current with live — if the site still looks " + "stale, the freeze is downstream (web cache / frontend polling).") + + +async def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("tournament_ids", nargs="*", type=int, + help="tournament ids to diagnose (default: auto-detect frozen ones)") + parser.add_argument("--top", type=int, default=5, + help="how many auto-detected tournaments to deep-scan (default 5)") + args = parser.parse_args() + now = int(time.time()) + + print(f"battles db: {TSS_BATTLES_DB_PATH}") + print(f"tournament db: {TSS_TOURNAMENTS_DB_PATH}") + + if args.tournament_ids: + targets = [(tid, None) for tid in args.tournament_ids] + else: + candidates = frozen_candidates() + print(f"\nAuto-detected {len(candidates)} tournament(s) with a game ingested " + f"after their last scan (stale brackets):") + for tid, name, newest, sunix in candidates[: args.top * 4]: + print(f" {tid:>8} game {_age(newest, now):>10} " + f"scan {_age(sunix, now):>10} {name or ''}") + if not candidates: + print(" (none — no tournament has games newer than its last scan)") + return + print(f"\nDeep-scanning the top {min(args.top, len(candidates))}…") + targets = [(tid, name) for tid, name, _, _ in candidates[: args.top]] + + for tid, name in targets: + await diagnose(tid, name, now) + + +if __name__ == "__main__": + asyncio.run(main())