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:
+321
-67
@@ -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,20 +701,87 @@ def fetch_battles_for_matches(
|
||||
return battles
|
||||
|
||||
|
||||
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:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
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,),
|
||||
).fetchall()
|
||||
return [{"match_id": r[0], "type_bracket": r[1]} for r in rows]
|
||||
|
||||
|
||||
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
|
||||
(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
|
||||
],
|
||||
)
|
||||
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."""
|
||||
_store_scan_sync(scan)
|
||||
await run_in_thread(_store_scan_sync, scan)
|
||||
|
||||
|
||||
def _store_scan_sync(scan: Dict[str, Any]) -> None:
|
||||
"""Synchronous implementation for ``store_scan``; run off the event loop."""
|
||||
def _upsert_tournament_meta(conn: sqlite3.Connection, scan: Dict[str, Any]) -> None:
|
||||
tid = scan["tournament_id"]
|
||||
now = scan["scanned_unix"]
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO tournaments
|
||||
@@ -710,11 +807,10 @@ def _store_scan_sync(scan: Dict[str, Any]) -> None:
|
||||
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,))
|
||||
|
||||
|
||||
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
|
||||
@@ -730,25 +826,13 @@ def _store_scan_sync(scan: Dict[str, Any]) -> None:
|
||||
m["team_b_name"], m["winner_name"], m["score_a"], m["score_b"],
|
||||
m["status"], m["time_start"],
|
||||
)
|
||||
for m in scan["matches"]
|
||||
],
|
||||
)
|
||||
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 scan["battles"]
|
||||
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
|
||||
@@ -762,23 +846,83 @@ def _store_scan_sync(scan: Dict[str, Any]) -> None:
|
||||
s["points"], s["wins"], s["draws"], s["losses"],
|
||||
s["buchholz"], s["rank"],
|
||||
)
|
||||
for s in scan["standings"]
|
||||
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()
|
||||
|
||||
@@ -222,14 +222,50 @@ def test_compute_status():
|
||||
assert tt.compute_status([], None, 1000) == "pending"
|
||||
assert tt.compute_status(played, None, 1000) == "finished"
|
||||
assert tt.compute_status(mixed, None, 1000) == "active"
|
||||
# past end + buffer → finished even with pending matches
|
||||
assert tt.compute_status(mixed, 100, 100 + tt.RESCAN_END_BUFFER_SECONDS + 1) == "finished"
|
||||
# A tournament still listed as active is NOT finished just because it overran
|
||||
# its *scheduled* dateEndTournament — finals routinely get played late.
|
||||
assert tt.compute_status(mixed, 100, 100 + tt.RESCAN_END_BUFFER_SECONDS + 1) == "active"
|
||||
assert tt.compute_status(mixed, 100, 100 + tt.RESCAN_END_BUFFER_SECONDS + 1,
|
||||
in_active=True) == "active"
|
||||
# absent from the active list → finished (old tournament, no date_end)
|
||||
assert tt.compute_status(mixed, None, 1000, in_active=False) == "finished"
|
||||
# unknown active-list state should not finish a tournament by absence alone
|
||||
# active-list state unknown → fall back to the scheduled end + buffer
|
||||
assert tt.compute_status(mixed, 100, 100 + tt.RESCAN_END_BUFFER_SECONDS + 1,
|
||||
in_active=None) == "finished"
|
||||
# unknown active-list state without a past end should not finish by absence alone
|
||||
assert tt.compute_status(mixed, None, 1000, in_active=None) == "active"
|
||||
|
||||
|
||||
def test_needs_scan_overrun_keeps_scanning():
|
||||
# A live tournament past its *scheduled* end must stay eligible for re-scan so
|
||||
# a late final still lands; only status=='finished' permanently stops re-scans.
|
||||
async def run():
|
||||
await tt.init_tss_tournaments_db()
|
||||
now = 1_000_000
|
||||
match = {
|
||||
"match_id": "m1", "type_bracket": "Winner", "side": "winner",
|
||||
"round": 0, "position": 0, "team_a_uuid": None, "team_a_name": "A",
|
||||
"team_b_uuid": None, "team_b_name": "B", "winner_name": None,
|
||||
"score_a": 0, "score_b": 0, "status": "pending", "time_start": None,
|
||||
}
|
||||
scan = {
|
||||
"tournament_id": 77777, "name": "Overrun Cup", "format": "single-elim",
|
||||
"game_mode": "RB", "cluster": "EU", "date_start": 1,
|
||||
"date_end": now - 10 * tt.RESCAN_END_BUFFER_SECONDS, # long past scheduled end
|
||||
"team_count": 2, "match_count": 1, "status": "active",
|
||||
"scanned_unix": now - tt.RESCAN_INTERVAL_SECONDS - 1, # interval elapsed
|
||||
"matches": [match], "battles": [], "standings": [],
|
||||
}
|
||||
await tt.store_scan(scan)
|
||||
assert await tt.needs_scan(77777, now=now) is True
|
||||
# Once truly finished, never re-scan again.
|
||||
scan["status"] = "finished"
|
||||
await tt.store_scan(scan)
|
||||
assert await tt.needs_scan(77777, now=now) is False
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_store_scan_roundtrip():
|
||||
names = tt.team_name_map(SCHEDULE["all_teams"])
|
||||
matches = [tt.parse_schedule_match(r, names) for r in SCHEDULE["all_matches"]]
|
||||
@@ -277,3 +313,180 @@ async def _restore_and_count(scan):
|
||||
"SELECT COUNT(*) FROM tournament_matches WHERE tournament_id=99999"
|
||||
).fetchone()
|
||||
assert mcount == 2
|
||||
|
||||
|
||||
def test_fetch_schedule_sync_has_no_battles_and_makes_no_battle_calls(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_request(method, action, **params):
|
||||
calls.append(action)
|
||||
if action == "get_shedule_matches":
|
||||
return {"data": SCHEDULE}
|
||||
return {"data": {}}
|
||||
|
||||
monkeypatch.setattr(tt, "_request", fake_request)
|
||||
monkeypatch.setattr(tt, "_active_meta", lambda tid, now: ({}, True))
|
||||
|
||||
scan = tt.fetch_schedule_sync(12345, now=1000)
|
||||
assert scan["battles"] == []
|
||||
assert scan["match_count"] == len(SCHEDULE["all_matches"])
|
||||
assert "getListAllBattles" not in calls # the whole point: no per-match battle loop
|
||||
|
||||
|
||||
def test_store_schedule_preserves_battles():
|
||||
# A schedule refresh updates matches/standings/status but must NOT wipe the
|
||||
# battle links the fast path inserted.
|
||||
names = tt.team_name_map(SCHEDULE["all_teams"])
|
||||
matches = [tt.parse_schedule_match(r, names) for r in SCHEDULE["all_matches"]]
|
||||
battles, _ = tt.parse_battles(BATTLES_PLAYED, 88888, "686556", "Looser")
|
||||
full = {
|
||||
"tournament_id": 88888, "name": "Cup", "format": "double-elim",
|
||||
"game_mode": "RB", "cluster": "EU", "date_start": 1, "date_end": 2,
|
||||
"team_count": 2, "match_count": len(matches), "status": "active",
|
||||
"scanned_unix": 1000, "matches": matches, "battles": battles, "standings": [],
|
||||
}
|
||||
|
||||
async def run():
|
||||
await tt.init_tss_tournaments_db()
|
||||
await tt.store_scan(full) # seeds matches + battles
|
||||
# Now a schedule-only refresh with NO battles in the payload.
|
||||
sched = dict(full, status="finished", scanned_unix=2000, battles=[])
|
||||
await tt.store_schedule(sched)
|
||||
import sqlite3
|
||||
with sqlite3.connect(tt.TSS_TOURNAMENTS_DB_PATH) as conn:
|
||||
n_battles = conn.execute(
|
||||
"SELECT COUNT(*) FROM tournament_battles WHERE tournament_id = 88888"
|
||||
).fetchone()[0]
|
||||
status = conn.execute(
|
||||
"SELECT status FROM tournaments WHERE tournament_id = 88888"
|
||||
).fetchone()[0]
|
||||
assert n_battles == len(battles) # battles preserved
|
||||
assert status == "finished" # meta updated
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_request_schedule_refresh_coalesces_burst(monkeypatch):
|
||||
fetched = []
|
||||
|
||||
def fake_fetch(tid, **kwargs):
|
||||
fetched.append(tid)
|
||||
return {
|
||||
"tournament_id": tid, "name": "T", "format": "single-elim",
|
||||
"game_mode": "RB", "cluster": "EU", "date_start": 1, "date_end": 2,
|
||||
"team_count": 0, "match_count": 0, "status": "active",
|
||||
"scanned_unix": 1000, "matches": [], "standings": [], "battles": [],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(tt, "fetch_schedule_sync", fake_fetch)
|
||||
monkeypatch.setattr(tt, "_REFRESH_DEBOUNCE_SECONDS", 0.05)
|
||||
|
||||
async def run():
|
||||
await tt.init_tss_tournaments_db()
|
||||
for _ in range(8): # burst of 8 games for the same tournament
|
||||
tt.request_schedule_refresh(60002)
|
||||
await asyncio.sleep(0.2) # let the single debounced refresh fire
|
||||
|
||||
asyncio.run(run())
|
||||
assert fetched == [60002] # 8 requests collapsed into one fetch
|
||||
|
||||
|
||||
def test_link_battle_sync_links_known_match():
|
||||
async def run():
|
||||
await tt.init_tss_tournaments_db()
|
||||
match = {
|
||||
"match_id": "330669", "type_bracket": "Swiss", "side": "swiss", "round": None,
|
||||
"position": None, "team_a_uuid": None, "team_a_name": "NEXOOOS",
|
||||
"team_b_uuid": None, "team_b_name": "GFRIS2", "winner_name": None,
|
||||
"score_a": 0, "score_b": 0, "status": "pending", "time_start": None,
|
||||
}
|
||||
scan = {
|
||||
"tournament_id": 24836, "name": "ACL", "format": "swiss", "game_mode": "RB",
|
||||
"cluster": "EU", "date_start": 1, "date_end": 2, "team_count": 2,
|
||||
"match_count": 1, "status": "active", "scanned_unix": 1000,
|
||||
"matches": [match], "battles": [], "standings": [],
|
||||
}
|
||||
await tt.store_scan(scan)
|
||||
linked = tt.link_battle_sync(24836, "330669", "1abcd", winner_name="NEXOOOS")
|
||||
assert linked is True
|
||||
import sqlite3
|
||||
with sqlite3.connect(tt.TSS_TOURNAMENTS_DB_PATH) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT type_bracket FROM tournament_battles WHERE session_hex = '1abcd'"
|
||||
).fetchone()
|
||||
assert row == ("Swiss",) # type_bracket pulled from the stored match
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_link_battle_sync_returns_false_for_unknown_match():
|
||||
async def run():
|
||||
await tt.init_tss_tournaments_db()
|
||||
assert tt.link_battle_sync(999999, "nope", "deadbeef") is False
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_maybe_link_battle_known_match_makes_no_network_call(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise AssertionError("fast path must not call the TSS API")
|
||||
|
||||
monkeypatch.setattr(tt, "_request", boom)
|
||||
refreshed = []
|
||||
monkeypatch.setattr(tt, "request_schedule_refresh",
|
||||
lambda tid, **k: refreshed.append(tid))
|
||||
|
||||
async def run():
|
||||
await tt.init_tss_tournaments_db()
|
||||
match = {
|
||||
"match_id": "330669", "type_bracket": "Swiss", "side": "swiss", "round": None,
|
||||
"position": None, "team_a_uuid": None, "team_a_name": "NEXOOOS",
|
||||
"team_b_uuid": None, "team_b_name": "GFRIS2", "winner_name": None,
|
||||
"score_a": 0, "score_b": 0, "status": "pending", "time_start": None,
|
||||
}
|
||||
scan = {
|
||||
"tournament_id": 24836, "name": "ACL", "format": "swiss", "game_mode": "RB",
|
||||
"cluster": "EU", "date_start": 1, "date_end": 2, "team_count": 2,
|
||||
"match_count": 1, "status": "active", "scanned_unix": 1000,
|
||||
"matches": [match], "battles": [], "standings": [],
|
||||
}
|
||||
await tt.store_scan(scan)
|
||||
game = {
|
||||
"_id": "1abcd", "winner": "1",
|
||||
"tss": {"tournament_id": 24836, "match_id": "330669", "bracket": "swiss",
|
||||
"1": {"name": "NEXOOOS"}, "2": {"name": "GFRIS2"}},
|
||||
}
|
||||
await tt.maybe_link_battle(game)
|
||||
|
||||
asyncio.run(run())
|
||||
assert refreshed == [24836] # linked + scheduled a refresh, no network
|
||||
|
||||
|
||||
def test_reconcile_targets_only_played_without_battles():
|
||||
async def run():
|
||||
await tt.init_tss_tournaments_db()
|
||||
m_played = {
|
||||
"match_id": "p1", "type_bracket": "Winner", "side": "winner", "round": 0,
|
||||
"position": 0, "team_a_uuid": None, "team_a_name": "A", "team_b_uuid": None,
|
||||
"team_b_name": "B", "winner_name": "A", "score_a": 1, "score_b": 0,
|
||||
"status": "played", "time_start": None,
|
||||
}
|
||||
m_pending = dict(m_played, match_id="p2", status="pending", winner_name=None)
|
||||
m_linked = dict(m_played, match_id="p3")
|
||||
battle = {
|
||||
"session_hex": "abc", "session_decimal": "2748", "tournament_id": 70001,
|
||||
"match_id": "p3", "type_bracket": "Winner", "position": 0,
|
||||
"winner_name": "A", "status_replay": "view replay",
|
||||
}
|
||||
scan = {
|
||||
"tournament_id": 70001, "name": "T", "format": "single-elim",
|
||||
"game_mode": "RB", "cluster": "EU", "date_start": 1, "date_end": 2,
|
||||
"team_count": 2, "match_count": 3, "status": "active", "scanned_unix": 1000,
|
||||
"matches": [m_played, m_pending, m_linked], "battles": [battle], "standings": [],
|
||||
}
|
||||
await tt.store_scan(scan)
|
||||
targets = tt.reconcile_targets_sync(70001)
|
||||
ids = {t["match_id"] for t in targets}
|
||||
assert ids == {"p1"} # played + no battle; p2 pending excluded, p3 already linked
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
@@ -178,13 +178,14 @@ async def _handle_game(game: Dict[str, Any]) -> None:
|
||||
await publish_replay_batch([game])
|
||||
except Exception as exc:
|
||||
log.warning("[TSS-BRIDGE] Failed to forward replay batch for %s: %s", sid, exc)
|
||||
# If this game belongs to a tournament we haven't indexed (or one that's live
|
||||
# and stale), scan its authoritative bracket in the background. Never blocks.
|
||||
# Link this game's replay to its bracket slot from tss.match_id (zero TSS
|
||||
# calls) and request a debounced schedule refresh; unknown matches fall back
|
||||
# to a full scan inside maybe_link_battle. Never blocks ingest.
|
||||
try:
|
||||
from BOT.tss_tournaments import maybe_scan_tournament
|
||||
await maybe_scan_tournament(game)
|
||||
from BOT.tss_tournaments import maybe_link_battle
|
||||
await maybe_link_battle(game)
|
||||
except Exception as exc:
|
||||
log.error("tournament scan trigger failed for %s: %s", sid, exc)
|
||||
log.error("tournament link trigger failed for %s: %s", sid, exc)
|
||||
# Autolog match/dispatch (no-ops in standalone mode where no bot is set).
|
||||
try:
|
||||
await autolog_process_game(game)
|
||||
|
||||
Reference in New Issue
Block a user