This commit is contained in:
NotSoToothless
2026-06-29 02:28:49 -07:00
committed by GitHub
parent 2b792b4d0b
commit 43c2837f6d
+252
View File
@@ -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())