Auto merge dev → main (#1361)

* fix: don't freeze tournament brackets that overrun their scheduled end

date_end (TSS dateEndTournament) is only the *scheduled* end; tournaments
overrun it. compute_status now trusts in_active (GetActiveTournaments
presence) over date_end, and needs_scan no longer disables re-scans past
date_end + buffer. Fixes brackets stuck while their final was still pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor: split _store_scan_sync into reusable upsert helpers

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: store_schedule writes meta/matches/standings without wiping battles

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor: extract gather_structure_sync; add fetch_schedule_sync (no battles)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: reconcile_battles gap-fills replay links for played matches only

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: debounced schedule refresh coalesces game bursts into one TSS call

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: maybe_link_battle links replays from game match_id with zero TSS calls

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: route received games through maybe_link_battle (fast-path bracket link)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* tss_tournaments: three small final-review cleanups

- link_battle_sync: drop unused team_a_name/team_b_name from SELECT and
  fix status index from r[3] to r[1]
- _replace_battles: add clarifying comment about fast-path caveat and
  that schedule refresh deliberately skips this function
- store_scan / store_schedule: dispatch sqlite writes off the event loop
  via run_in_thread instead of calling sync writers directly

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
NotSoToothless
2026-06-29 04:23:34 -07:00
committed by GitHub
parent 43c2837f6d
commit a5abecb918
3 changed files with 592 additions and 124 deletions
+370 -116
View File
@@ -448,20 +448,28 @@ def compute_status(
now: int,
in_active: Optional[bool] = True,
) -> str:
"""Tournament status from match completion + end date + live-list presence.
"""Tournament status from match completion + live-list presence + end date.
A tournament absent from ``GetActiveTournaments`` (``in_active`` False) is over
— this is the reliable finished signal for old tournaments that carry no
``date_end``, and it stops ``needs_scan`` from re-scanning them forever.
Presence in ``GetActiveTournaments`` is the authoritative live/over signal:
absence (``in_active`` False) is over, presence (True) is live. ``date_end``
is only TSS's *scheduled* end, which tournaments routinely overrun (finals run
hours/days late), so it must NOT finish a tournament that is still listed as
active — that froze brackets whose final landed after the scheduled end. The
``date_end`` heuristic is therefore a fallback used only when the active-list
state is unknown (``in_active`` None).
"""
if in_active is False:
return "finished"
if not matches:
return "pending"
if date_end and now > date_end + RESCAN_END_BUFFER_SECONDS:
return "finished"
if all(m["status"] in ("played", "bye", "technical") for m in matches):
return "finished"
# Has unplayed matches.
if in_active is True:
return "active" # still live/upcoming — overrunning the scheduled end is fine
# in_active is None: active list unavailable — fall back to scheduled end + buffer.
if date_end and now > date_end + RESCAN_END_BUFFER_SECONDS:
return "finished"
return "active"
@@ -537,20 +545,15 @@ def _active_meta(tournament_id: int, now: int) -> Tuple[Optional[Dict[str, Any]]
return meta, meta is not None
def build_scan_sync(
def gather_structure_sync(
tournament_id: int,
*,
fallback_name: Optional[str] = None,
active_meta: Optional[Dict[str, Any]] = None,
now: Optional[int] = None,
battle_workers: int = _DEFAULT_BATTLE_WORKERS,
progress: Optional[Callable[[str], None]] = None,
) -> Dict[str, Any]:
"""Fetch + assemble the full authoritative structure for one tournament.
Network-bound; call via ``run_in_thread``. Returns a dict of rows ready for
``store_scan``.
"""
"""Fetch + assemble the bracket structure (matches, standings, meta) for one
tournament. Network-bound; call via ``run_in_thread``. No battles fetched."""
now = now or int(time.time())
if active_meta is None:
active_meta, in_active = _active_meta(tournament_id, now)
@@ -566,14 +569,12 @@ def build_scan_sync(
if raw_matches:
matches = [parse_schedule_match(r, names) for r in raw_matches if isinstance(r, dict)]
else:
# Empty schedule can mean bracket fallback, swiss/group, or pre-start.
bracket = _data_dict(_request("POST", "GetArrayBracketData", tournamentID=tournament_id))
bracket_rows = collect_group_rows(bracket.get("bracket") or bracket)
if bracket_rows:
matches = [parse_schedule_match(r, names) for r in bracket_rows if isinstance(r, dict)]
if not matches:
# Empty schedule/bracket → swiss/group. Try swiss, then group.
for action, kw in (
("GetArraySwissData", {"tournamentID": tournament_id, "id_group": 1}),
("GetArrayGroupData", {"tournamentID": tournament_id}),
@@ -587,26 +588,6 @@ def build_scan_sync(
apply_tournament_side_context(matches)
# Battles per match → session links. Dedupe match_ids (same id can repeat
# across sources); fetch once per (match_id, type_bracket).
battle_targets: List[Dict[str, Any]] = []
seen_battle_keys = set()
for match in matches:
mid, tb = match["match_id"], match["type_bracket"]
if not mid or (mid, tb) in seen_battle_keys:
continue
seen_battle_keys.add((mid, tb))
battle_targets.append(match)
if progress:
progress(f"{len(matches)} matches; fetching battles for {len(battle_targets)} match rows")
battles = fetch_battles_for_matches(
tournament_id,
battle_targets,
workers=max(1, battle_workers),
progress=progress,
)
type_set = {m["type_bracket"] for m in matches}
meta = active_meta or {}
name = meta.get("nameEN") or fallback_name
@@ -626,11 +607,60 @@ def build_scan_sync(
"status": compute_status(matches, date_end, now, in_active),
"scanned_unix": now,
"matches": matches,
"battles": battles,
"standings": standings,
"in_active": in_active,
}
def fetch_schedule_sync(
tournament_id: int,
*,
fallback_name: Optional[str] = None,
now: Optional[int] = None,
) -> Dict[str, Any]:
"""Schedule-only gather (no battles), ready for ``store_schedule``."""
scan = gather_structure_sync(tournament_id, fallback_name=fallback_name, now=now)
scan["battles"] = []
return scan
def build_scan_sync(
tournament_id: int,
*,
fallback_name: Optional[str] = None,
active_meta: Optional[Dict[str, Any]] = None,
now: Optional[int] = None,
battle_workers: int = _DEFAULT_BATTLE_WORKERS,
progress: Optional[Callable[[str], None]] = None,
) -> Dict[str, Any]:
"""Fetch + assemble the full authoritative structure incl. battles."""
now = now or int(time.time())
scan = gather_structure_sync(
tournament_id, fallback_name=fallback_name, active_meta=active_meta, now=now
)
matches = scan["matches"]
battle_targets: List[Dict[str, Any]] = []
seen_battle_keys = set()
for match in matches:
mid, tb = match["match_id"], match["type_bracket"]
if not mid or (mid, tb) in seen_battle_keys:
continue
seen_battle_keys.add((mid, tb))
battle_targets.append(match)
if progress:
progress(f"{len(matches)} matches; fetching battles for {len(battle_targets)} match rows")
battles = fetch_battles_for_matches(
tournament_id, battle_targets, workers=max(1, battle_workers), progress=progress
)
scan["battles"] = battles
# Recompute status: fetch_battles_for_matches may have flipped matches to
# 'technical', which changes whether the tournament reads finished.
scan["status"] = compute_status(matches, scan["date_end"], now, scan["in_active"])
return scan
def fetch_battles_for_matches(
tournament_id: int,
matches: List[Dict[str, Any]],
@@ -671,68 +701,34 @@ def fetch_battles_for_matches(
return battles
# ---------------------------------------------------------------------------
# Storage
# ---------------------------------------------------------------------------
async def store_scan(scan: Dict[str, Any]) -> None:
"""Upsert one assembled scan into ``tss_tournaments.db`` transactionally."""
_store_scan_sync(scan)
def _store_scan_sync(scan: Dict[str, Any]) -> None:
"""Synchronous implementation for ``store_scan``; run off the event loop."""
tid = scan["tournament_id"]
now = scan["scanned_unix"]
def reconcile_targets_sync(tid: int) -> List[Dict[str, str]]:
"""Matches that should have a replay link but don't yet: played/technical with
zero stored battles (a game we missed, or a not-yet-linked technical victory).
Byes (empty team slot) are excluded — no battle is expected."""
with _connect() as conn:
conn.execute(
rows = conn.execute(
"""
INSERT INTO tournaments
(tournament_id, name, format, game_mode, cluster, date_start,
date_end, team_count, match_count, status, first_seen_unix, scanned_unix)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(tournament_id) DO UPDATE SET
name=COALESCE(excluded.name, tournaments.name),
format=excluded.format,
game_mode=COALESCE(excluded.game_mode, tournaments.game_mode),
cluster=COALESCE(excluded.cluster, tournaments.cluster),
date_start=COALESCE(excluded.date_start, tournaments.date_start),
date_end=COALESCE(excluded.date_end, tournaments.date_end),
team_count=excluded.team_count,
match_count=excluded.match_count,
status=excluded.status,
scanned_unix=excluded.scanned_unix
SELECT m.match_id, m.type_bracket
FROM tournament_matches m
WHERE m.tournament_id = ?
AND m.status IN ('played', 'technical')
AND NOT EXISTS (
SELECT 1 FROM tournament_battles b
WHERE b.tournament_id = m.tournament_id
AND b.match_id = m.match_id
AND b.type_bracket = m.type_bracket
)
""",
(
tid, scan.get("name"), scan.get("format"), scan.get("game_mode"),
scan.get("cluster"), scan.get("date_start"), scan.get("date_end"),
scan.get("team_count", 0), scan.get("match_count", 0),
scan.get("status", "pending"), now, now,
),
)
# Replace child rows for this tournament (authoritative snapshot).
conn.execute("DELETE FROM tournament_matches WHERE tournament_id = ?", (tid,))
conn.execute("DELETE FROM tournament_battles WHERE tournament_id = ?", (tid,))
conn.execute("DELETE FROM tournament_standings WHERE tournament_id = ?", (tid,))
(tid,),
).fetchall()
return [{"match_id": r[0], "type_bracket": r[1]} for r in rows]
conn.executemany(
"""
INSERT OR REPLACE INTO tournament_matches
(tournament_id, match_id, type_bracket, side, round, position,
team_a_uuid, team_a_name, team_b_uuid, team_b_name, winner_name,
score_a, score_b, status, time_start)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""",
[
(
tid, m["match_id"], m["type_bracket"], m["side"], m["round"],
m["position"], m["team_a_uuid"], m["team_a_name"], m["team_b_uuid"],
m["team_b_name"], m["winner_name"], m["score_a"], m["score_b"],
m["status"], m["time_start"],
)
for m in scan["matches"]
],
)
def _insert_battles_sync(battles: List[Dict[str, Any]]) -> None:
"""Upsert battle rows without deleting anything (preserves fast-path links)."""
if not battles:
return
with _connect() as conn:
conn.executemany(
"""
INSERT OR REPLACE INTO tournament_battles
@@ -746,39 +742,187 @@ def _store_scan_sync(scan: Dict[str, Any]) -> None:
b["match_id"], b["type_bracket"], b["position"],
b["winner_name"], b["status_replay"],
)
for b in scan["battles"]
],
)
conn.executemany(
"""
INSERT OR REPLACE INTO tournament_standings
(tournament_id, group_index, team_uuid, team_name, points,
wins, draws, losses, buchholz, rank)
VALUES (?,?,?,?,?,?,?,?,?,?)
""",
[
(
tid, s["group_index"], s["team_uuid"], s["team_name"],
s["points"], s["wins"], s["draws"], s["losses"],
s["buchholz"], s["rank"],
)
for s in scan["standings"]
for b in battles
],
)
conn.commit()
async def reconcile_battles(
tid: int,
*,
now: Optional[int] = None,
force: bool = False,
workers: int = _DEFAULT_BATTLE_WORKERS,
) -> int:
"""Fill replay links for played matches that have no stored session. Calls
getListAllBattles ONLY for those gap matches. Rate-limited unless ``force``."""
now = now or int(time.time())
if not force and (_reconcile_last.get(tid, 0) + _RECONCILE_INTERVAL_SECONDS > now):
return 0
targets = await run_in_thread(reconcile_targets_sync, tid)
if not targets:
_reconcile_last[tid] = now
return 0
battles = await run_in_thread(fetch_battles_for_matches, tid, targets, workers=workers)
await run_in_thread(_insert_battles_sync, battles)
_reconcile_last[tid] = now
return len(battles)
# ---------------------------------------------------------------------------
# Storage
# ---------------------------------------------------------------------------
async def store_scan(scan: Dict[str, Any]) -> None:
"""Upsert one assembled scan into ``tss_tournaments.db`` transactionally."""
await run_in_thread(_store_scan_sync, scan)
def _upsert_tournament_meta(conn: sqlite3.Connection, scan: Dict[str, Any]) -> None:
tid = scan["tournament_id"]
now = scan["scanned_unix"]
conn.execute(
"""
INSERT INTO tournaments
(tournament_id, name, format, game_mode, cluster, date_start,
date_end, team_count, match_count, status, first_seen_unix, scanned_unix)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(tournament_id) DO UPDATE SET
name=COALESCE(excluded.name, tournaments.name),
format=excluded.format,
game_mode=COALESCE(excluded.game_mode, tournaments.game_mode),
cluster=COALESCE(excluded.cluster, tournaments.cluster),
date_start=COALESCE(excluded.date_start, tournaments.date_start),
date_end=COALESCE(excluded.date_end, tournaments.date_end),
team_count=excluded.team_count,
match_count=excluded.match_count,
status=excluded.status,
scanned_unix=excluded.scanned_unix
""",
(
tid, scan.get("name"), scan.get("format"), scan.get("game_mode"),
scan.get("cluster"), scan.get("date_start"), scan.get("date_end"),
scan.get("team_count", 0), scan.get("match_count", 0),
scan.get("status", "pending"), now, now,
),
)
def _replace_matches(conn: sqlite3.Connection, tid: int, matches: List[Dict[str, Any]]) -> None:
conn.execute("DELETE FROM tournament_matches WHERE tournament_id = ?", (tid,))
conn.executemany(
"""
INSERT OR REPLACE INTO tournament_matches
(tournament_id, match_id, type_bracket, side, round, position,
team_a_uuid, team_a_name, team_b_uuid, team_b_name, winner_name,
score_a, score_b, status, time_start)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""",
[
(
tid, m["match_id"], m["type_bracket"], m["side"], m["round"],
m["position"], m["team_a_uuid"], m["team_a_name"], m["team_b_uuid"],
m["team_b_name"], m["winner_name"], m["score_a"], m["score_b"],
m["status"], m["time_start"],
)
for m in matches
],
)
def _replace_standings(conn: sqlite3.Connection, tid: int, standings: List[Dict[str, Any]]) -> None:
conn.execute("DELETE FROM tournament_standings WHERE tournament_id = ?", (tid,))
conn.executemany(
"""
INSERT OR REPLACE INTO tournament_standings
(tournament_id, group_index, team_uuid, team_name, points,
wins, draws, losses, buchholz, rank)
VALUES (?,?,?,?,?,?,?,?,?,?)
""",
[
(
tid, s["group_index"], s["team_uuid"], s["team_name"],
s["points"], s["wins"], s["draws"], s["losses"],
s["buchholz"], s["rank"],
)
for s in standings
],
)
def _replace_battles(conn: sqlite3.Connection, tid: int, battles: List[Dict[str, Any]]) -> None:
# Authoritative full-scan replace. NOTE: this briefly drops any fast-path
# battle link (maybe_link_battle) for a session TSS has not yet listed in
# getListAllBattles; such a link is refilled by a later reconcile or scan.
# The debounced schedule refresh deliberately does NOT call this.
conn.execute("DELETE FROM tournament_battles WHERE tournament_id = ?", (tid,))
conn.executemany(
"""
INSERT OR REPLACE INTO tournament_battles
(session_hex, session_decimal, tournament_id, match_id,
type_bracket, position, winner_name, status_replay)
VALUES (?,?,?,?,?,?,?,?)
""",
[
(
b["session_hex"], b["session_decimal"], b["tournament_id"],
b["match_id"], b["type_bracket"], b["position"],
b["winner_name"], b["status_replay"],
)
for b in battles
],
)
def _store_scan_sync(scan: Dict[str, Any]) -> None:
"""Synchronous implementation for ``store_scan``; run off the event loop."""
tid = scan["tournament_id"]
with _connect() as conn:
_upsert_tournament_meta(conn, scan)
_replace_matches(conn, tid, scan["matches"])
_replace_battles(conn, tid, scan["battles"])
_replace_standings(conn, tid, scan["standings"])
conn.commit()
def store_schedule_sync(scan: Dict[str, Any]) -> None:
"""Persist a schedule-only refresh: meta + matches + standings.
Deliberately does NOT touch ``tournament_battles`` — those rows are
maintained incrementally by the fast-path linker and the reconcile, and a
schedule refresh that wiped them would drop every replay link.
"""
tid = scan["tournament_id"]
with _connect() as conn:
_upsert_tournament_meta(conn, scan)
_replace_matches(conn, tid, scan["matches"])
_replace_standings(conn, tid, scan["standings"])
conn.commit()
async def store_schedule(scan: Dict[str, Any]) -> None:
"""Async wrapper for ``store_schedule_sync``."""
await run_in_thread(store_schedule_sync, scan)
async def needs_scan(tournament_id: int, *, now: Optional[int] = None) -> bool:
"""True if we've never scanned this tournament, or it's live and stale."""
"""True if we've never scanned this tournament, or it's live and stale.
The only permanent stop is ``status == 'finished'`` (which ``compute_status``
sets reliably once the tournament drops out of ``GetActiveTournaments``). We
deliberately do NOT gate on ``date_end``: it is only the *scheduled* end, and
scans are game-triggered — a game arriving for a tournament is itself evidence
it is still live, even days past that scheduled end. Gating on ``date_end``
froze brackets whose final was played late. Re-scans stay interval-limited.
"""
now = now or int(time.time())
row = _scan_state_sync(tournament_id)
if row is None:
return True
status, date_end, scanned_unix = row
status, _date_end, scanned_unix = row
if status == "finished":
return False
if date_end and now > date_end + RESCAN_END_BUFFER_SECONDS:
return False
return (scanned_unix or 0) + RESCAN_INTERVAL_SECONDS <= now
@@ -793,6 +937,11 @@ def _scan_state_sync(tournament_id: int) -> Optional[Tuple[str, Optional[int], O
_db_ready = False
_inflight: set = set()
_tasks: set = set()
_RECONCILE_INTERVAL_SECONDS = 600
_reconcile_last: Dict[int, int] = {}
_REFRESH_DEBOUNCE_SECONDS = 30
_refresh_tasks: Dict[int, "asyncio.Task"] = {}
_refresh_names: Dict[int, Optional[str]] = {}
async def _ensure_db() -> None:
@@ -802,6 +951,83 @@ async def _ensure_db() -> None:
_db_ready = True
def link_battle_sync(
tid: int,
match_id: str,
session_hex: str,
*,
session_decimal: Optional[str] = None,
winner_name: Optional[str] = None,
) -> bool:
"""Link a captured replay to its match using the game's own ``match_id``.
Returns True if the match exists locally (battle inserted), False otherwise
so the caller can fall back to a full scan. No network access.
"""
with _connect() as conn:
rows = conn.execute(
"SELECT type_bracket, status "
"FROM tournament_matches WHERE tournament_id = ? AND match_id = ?",
(tid, match_id),
).fetchall()
if not rows:
return False
# Prefer a still-pending row when a match_id spans >1 type_bracket (rare).
chosen = next((r for r in rows if r[1] == "pending"), rows[0])
type_bracket = chosen[0]
if session_decimal is None:
try:
session_decimal = str(int(session_hex, 16))
except (ValueError, TypeError):
session_decimal = None
conn.execute(
"""
INSERT OR REPLACE INTO tournament_battles
(session_hex, session_decimal, tournament_id, match_id,
type_bracket, position, winner_name, status_replay)
VALUES (?,?,?,?,?,?,?,?)
""",
(session_hex, session_decimal, tid, match_id, type_bracket, None,
winner_name, "view replay"),
)
conn.commit()
return True
async def maybe_link_battle(game: Dict[str, Any]) -> None:
"""Trigger entry point for each received game: link its replay to the bracket
slot from ``tss.match_id`` (zero TSS calls) and request a debounced schedule
refresh. Falls back to a full scan when the match is not yet indexed."""
tss = game.get("tss") or {}
raw_tid = tss.get("tournament_id")
match_id = tss.get("match_id")
try:
tid = int(raw_tid)
except (TypeError, ValueError):
return
if tid <= 0:
return
await _ensure_db()
session_hex = str(game.get("_id") or "")
name = tss.get("tournament_name") or None
winner_slot = str(game.get("winner") or "")
winner_name = None
slot = tss.get(winner_slot) if winner_slot in ("1", "2") else None
if isinstance(slot, dict):
winner_name = slot.get("name")
if match_id and session_hex:
linked = await run_in_thread(
link_battle_sync, tid, str(match_id), session_hex, winner_name=winner_name
)
if linked:
request_schedule_refresh(tid, fallback_name=name)
return
# Unknown match (or missing match_id): fall back to the existing full scan.
await maybe_scan_tournament(game)
async def maybe_scan_tournament(game: Dict[str, Any]) -> None:
"""Trigger entry point: schedule a background scan if this game's tournament
is new or live-and-stale. Non-blocking and deduped per tournament id."""
@@ -847,6 +1073,34 @@ async def scan_and_store(
log.error("tournament scan failed for %s: %s", tournament_id, exc)
def request_schedule_refresh(tid: int, *, fallback_name: Optional[str] = None) -> None:
"""Schedule a debounced schedule-only refresh for ``tid``. Repeated calls
within the debounce window collapse into the single pending refresh."""
if fallback_name and not _refresh_names.get(tid):
_refresh_names[tid] = fallback_name
if tid in _refresh_tasks and not _refresh_tasks[tid].done():
return # a refresh is already pending; this game folds into it
task = asyncio.create_task(_run_schedule_refresh(tid))
_refresh_tasks[tid] = task
_tasks.add(task)
task.add_done_callback(_tasks.discard)
async def _run_schedule_refresh(tid: int) -> None:
try:
await asyncio.sleep(_REFRESH_DEBOUNCE_SECONDS)
name = _refresh_names.pop(tid, None)
scan = await run_in_thread(fetch_schedule_sync, tid, fallback_name=name)
await store_schedule(scan)
await reconcile_battles(tid, force=(scan["status"] == "finished"))
log.info("schedule refresh %s: %d matches (%s)",
tid, scan["match_count"], scan["status"])
except Exception as exc: # pragma: no cover - network/transient
log.error("schedule refresh failed for %s: %s", tid, exc)
finally:
_refresh_tasks.pop(tid, None)
async def run_in_thread(fn, *args, **kwargs):
"""Run a blocking callable without relying on asyncio's default executor."""
loop = asyncio.get_running_loop()