Compare commits

...

10 Commits

Author SHA1 Message Date
deploy c0214eaaae fix(recap): speed up /card lookup, fix stale completed-season cache, add Place Finished
- /card player lookup was a leading-wildcard substring match with a Python
  ulower() UDF over 6.16M player_games_hist rows — a full scan measured at
  27s live before the ~2s render. Add a prefix fast-path
  (nick LIKE 'name%' COLLATE NOCASE) that uses the existing NOCASE index
  (~1ms), falling back to the ulower substring scan only when the prefix
  finds nothing. Same fix applied to _resolve_player_uids_batch (compare/stats).
  Lookup: 27,205ms -> 1ms.

- Completed-season recap cache served ANY cached PNG forever, including files
  rendered mid-season (frozen at whatever point they were last viewed). Only
  serve from cache when the file's mtime is after season end; otherwise
  re-render. Fixed in both BOT/utils.py and web/server.js (shared cache).

- Replace squadron card "Rating change" with "Place finished" (#rank / total),
  derived by ranking clans by final total_score among those active in-season.

- Add (CARD)/(RECAP) timing logs for prod observability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:20:26 +00:00
deploy 61236a8267 fix: tolerate season-reset payloads in /sq-info and stop duplicate error embeds
obtain_clan_new_points treated a missing `astat` or `member_ratings` block as
"squadron unavailable or deleted". After a season reset the game API omits those
blocks for inactive/unranked squadrons even though the clan and its roster still
exist, so /sq-info wrongly reported real squadrons (e.g. DSPL, 513th) as
nonexistent. Require a `members` roster to consider the payload valid (genuinely
deleted clans return CLAN_IS_NOT_EXISTS JSON handled upstream) and default both
stat blocks to zero, so the squadron renders at 0 points instead of erroring.

permission_fail was invoked twice per error because discord.py dispatches to
both the command-local `@cmd.error` handler and the global `@tree.error`
handler. Combined with a public defer, this produced a duplicated error message
(one public, one ephemeral). Guard on interaction.extras so the embed is sent
at most once per interaction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:28:22 +00:00
NotSoToothless 3af950b464 update schedule and icons and vromfs (#1365) 2026-07-01 04:57:03 -07:00
deploy 659785f8f3 perf: leaderboard SWR cache + threadpool fix for season-III stalls
- Fix 1: UV_THREADPOOL_SIZE=24 via start_server.sh wrapper (libuv reads OS
  environ; process.env and PM2 env blocks don't propagate on this system)
- Fix 2: Stale-while-revalidate for leaderboards — serve cached/stale data
  instantly, refresh in background; dedicated aggregateCache isolated from
  the 100-entry responseCache; single-flight dedup for concurrent computes
- Fix 3: Background warmer precomputes current + last-completed season
  leaderboards at +20s boot and every 4 min
- Fix 5: Adaptive TTL (5 min live, 24 h completed) via aggregateCacheTtl
- Fixes 1+2 combined: player page stall 95s -> 3.6s under concurrent heavy
  leaderboard load; warm hits served in 1-4ms (was 13-53s)
2026-06-30 12:08:02 +00:00
NotSoToothless 0f8f22df29 update for spectra changes (#1363) 2026-06-29 11:05:51 -07:00
deploy 010e356dc8 pm2: add crash-loop governor to all apps; lower srebot max_memory_restart to 12000M
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 12:58:56 +00:00
FURRO404 a58a4e5c42 webhook: restart relay-gateway on SHARED/relay_gateway changes, tssbot-api on TSSBOT/web changes 2026-06-28 07:13:03 -07:00
FURRO404 0b3c6f478e fix: proxy without channel prefix for SREBOT, revert server.js rewrite 2026-06-28 06:51:33 -07:00
FURRO404 820073f95c add rewrite debug script 2026-06-28 06:50:43 -07:00
NotSoToothless 18faa665ec fix sre rewrite slice offset: /api/sre is 8 chars, not 9 (#1359) 2026-06-28 06:43:39 -07:00
15 changed files with 434 additions and 176 deletions
+44 -44
View File
@@ -1,65 +1,65 @@
[
{
"max_br": 14.3,
"start": 1777618800,
"end": 1778223600,
"start_discord": "<t:1777618800:D>",
"end_discord": "<t:1778223600:D>"
"max_br": 14.7,
"start": 1782889200,
"end": 1783494000,
"start_discord": "<t:1782889200:D>",
"end_discord": "<t:1783494000:D>"
},
{
"max_br": 12.0,
"start": 1778223600,
"end": 1778828400,
"start_discord": "<t:1778223600:D>",
"end_discord": "<t:1778828400:D>"
"start": 1783494000,
"end": 1784098800,
"start_discord": "<t:1783494000:D>",
"end_discord": "<t:1784098800:D>"
},
{
"max_br": 11.0,
"start": 1778828400,
"end": 1779433200,
"start_discord": "<t:1778828400:D>",
"end_discord": "<t:1779433200:D>"
"max_br": 10.7,
"start": 1784098800,
"end": 1784703600,
"start_discord": "<t:1784098800:D>",
"end_discord": "<t:1784703600:D>"
},
{
"max_br": 10.0,
"start": 1779433200,
"end": 1780038000,
"start_discord": "<t:1779433200:D>",
"end_discord": "<t:1780038000:D>"
"max_br": 9.7,
"start": 1784703600,
"end": 1785308400,
"start_discord": "<t:1784703600:D>",
"end_discord": "<t:1785308400:D>"
},
{
"max_br": 9.0,
"start": 1780038000,
"end": 1780642800,
"start_discord": "<t:1780038000:D>",
"end_discord": "<t:1780642800:D>"
"max_br": 8.7,
"start": 1785308400,
"end": 1785913200,
"start_discord": "<t:1785308400:D>",
"end_discord": "<t:1785913200:D>"
},
{
"max_br": 8.0,
"start": 1780642800,
"end": 1781247600,
"start_discord": "<t:1780642800:D>",
"end_discord": "<t:1781247600:D>"
"max_br": 7.3,
"start": 1785913200,
"end": 1786518000,
"start_discord": "<t:1785913200:D>",
"end_discord": "<t:1786518000:D>"
},
{
"max_br": 7.0,
"start": 1781247600,
"end": 1781852400,
"start_discord": "<t:1781247600:D>",
"end_discord": "<t:1781852400:D>"
"max_br": 6.3,
"start": 1786518000,
"end": 1787122800,
"start_discord": "<t:1786518000:D>",
"end_discord": "<t:1787122800:D>"
},
{
"max_br": 6.0,
"start": 1781852400,
"end": 1782457200,
"start_discord": "<t:1781852400:D>",
"end_discord": "<t:1782457200:D>"
"max_br": 5.7,
"start": 1787122800,
"end": 1787727600,
"start_discord": "<t:1787122800:D>",
"end_discord": "<t:1787727600:D>"
},
{
"max_br": 5.0,
"start": 1782457200,
"end": 1782889200,
"start_discord": "<t:1782457200:D>",
"end_discord": "<t:1782889200:D>"
"max_br": 4.7,
"start": 1787727600,
"end": 1788246000,
"start_discord": "<t:1787727600:D>",
"end_discord": "<t:1788246000:D>"
}
]
+9
View File
@@ -0,0 +1,9 @@
1 week мах BR 14.7 (01.07 — 07.07)
2 week мах BR 12.0 (08.07 — 14.07)
3 week мах BR 10.7 (15.07 — 21.07)
4 week мах BR 9.7 (22.07 — 28.07)
5 week мах BR 8.7 (29.07 — 04.08)
6 week мах BR 7.3 (05.08 — 11.08)
7 week мах BR 6.3 (12.08 — 18.08)
8 week мах BR 5.7 (19.08 — 25.08)
Until the end of season, мах BR 4.7 (26.08 — 31.08)
+48 -18
View File
@@ -3574,21 +3574,41 @@ async def card(
)
try:
_t_lookup = time_module.monotonic()
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db:
db.row_factory = aiosqlite.Row
await db.create_function("ulower", 1, str.lower)
async with db.execute(
"""
SELECT UID, MIN(nick) AS nick
FROM player_games_hist
WHERE ulower(nick) LIKE ulower(?)
GROUP BY UID
ORDER BY nick
LIMIT 25
""",
(f"%{player}%",),
) as cursor:
results = list(await cursor.fetchall())
async def _lookup(where: str, pattern: str) -> list:
async with db.execute(
f"""
SELECT UID, MIN(nick) AS nick
FROM player_games_hist
WHERE {where}
GROUP BY UID
ORDER BY nick
LIMIT 25
""",
(pattern,),
) as cursor:
return list(await cursor.fetchall())
# Fast path: a prefix match uses the `nick COLLATE NOCASE` index
# (~ms), covering the common case of typing the start of a name.
# This avoids a full scan of the multi-million-row games table.
results = await _lookup("nick LIKE ? COLLATE NOCASE", f"{player}%")
lookup_path = "prefix"
# Fallback: only if the prefix found nothing do we pay for a full
# substring scan. ulower() gives full Unicode case-folding (e.g.
# Cyrillic) that the ASCII-only NOCASE collation can't.
if not results:
await db.create_function("ulower", 1, str.lower)
results = await _lookup("ulower(nick) LIKE ulower(?)", f"%{player}%")
lookup_path = "substring"
logging.info(
"(CARD) nick lookup query=%r path=%s matches=%d ms=%d",
player, lookup_path, len(results), (time_module.monotonic() - _t_lookup) * 1000,
)
except Exception as e:
error_str = str(e)[:1800]
return await interaction.followup.send(
@@ -5024,19 +5044,29 @@ _LOWER_IS_BETTER = {"deaths", "losses"}
async def _resolve_player_uids_batch(db, usernames: List[str], lang: str = "en"):
"""Resolve multiple usernames in one pass. Returns (uid_list, error_msg) or (None, error_msg)."""
uids: List[str] = []
for name in usernames:
async def _resolve_one(where: str, pattern: str) -> list:
async with db.execute(
"""
f"""
SELECT UID, nick FROM (
SELECT UID, nick, ROW_NUMBER() OVER (PARTITION BY UID ORDER BY endtime_unix DESC) as rn
FROM player_games_hist
WHERE ulower(nick) LIKE ulower(?)
WHERE {where}
) WHERE rn = 1
ORDER BY nick LIMIT 25
""",
(f"%{name}%",),
(pattern,),
) as cursor:
results = list(await cursor.fetchall())
return list(await cursor.fetchall())
for name in usernames:
# Fast path: prefix match uses the nick COLLATE NOCASE index (~ms).
# Only fall back to a full substring scan (ulower for Unicode
# case-folding) when the prefix finds nothing.
results = await _resolve_one("nick LIKE ? COLLATE NOCASE", f"{name}%")
if not results:
await db.create_function("ulower", 1, str.lower)
results = await _resolve_one("ulower(nick) LIKE ulower(?)", f"%{name}%")
if not results:
return None, t(lang, "compare.no_players_found", name=name)
if len(results) > 1:
+14 -2
View File
@@ -655,8 +655,20 @@ async def obtain_clan_new_points(
while isinstance(data, dict) and "member_ratings" not in data and len(data) == 1:
data = next(iter(data.values()))
total_score = data["astat"]["dr_era5_hist"]
raw_ratings = data["member_ratings"]
# A real clan record always carries a `members` roster. Deleted
# clans come back as JSON (handled above), and truncated payloads
# raise inside bin_blk_to_json, so a parsed BLK without `members`
# is genuinely unavailable.
if not isinstance(data, dict) or "members" not in data:
raise KeyError("members")
# After a season reset the game API can omit the aggregate `astat`
# block and/or the per-member `member_ratings` block for inactive
# squadrons even though the clan and its roster still exist. Default
# both to zero so the squadron renders at 0 points instead of being
# reported as deleted.
total_score = (data.get("astat") or {}).get("dr_era5_hist", 0)
raw_ratings = data.get("member_ratings") or {}
except ClanInfoError as e:
try:
+6
View File
@@ -55,6 +55,12 @@ def normalize_ws_message(data: Any) -> Optional[List[Dict[str, Any]]]:
if isinstance(data, dict) and 'completed' in data:
return data['completed']
if isinstance(data, dict) and isinstance(data.get('data'), dict):
return [data['data']]
if isinstance(data, dict) and isinstance(data.get('data'), list):
return data['data']
logger.warning(f"Unknown WS message format: {type(data)}")
return None
+42 -1
View File
@@ -369,6 +369,43 @@ def gather_squadron_rating(conn_sq: sqlite3.Connection, clan_id: int,
first=first, change=final - first)
def gather_squadron_place(conn_sq: sqlite3.Connection, clan_id: int,
season_start: int, season_end: int
) -> Optional[Tuple[int, int]]:
"""Finishing leaderboard place for the season.
We don't store a per-season position, but ``total_score`` in
squadrons_points IS the squadron rating that the in-game leaderboard ranks
by. So rank clans by their final score and read off this clan's rank.
Only clans with at least one snapshot *within the season window* are ranked
— a clan whose last data predates the season never appeared on that season's
ladder (dead/inactive clans drop off in-game), so counting them would inflate
the denominator. Each ranked clan uses its last in-season snapshot.
Returns (place, total_ranked) or None if this clan wasn't active in-season.
Works for in-progress seasons too (season_end in the future → last snapshot
so far = current standing).
"""
cur = conn_sq.execute(
"SELECT p.clan_id, p.total_score "
"FROM squadrons_points p "
"JOIN (SELECT clan_id, MAX(unix_time) AS mt FROM squadrons_points "
" WHERE unix_time BETWEEN ? AND ? GROUP BY clan_id) m "
" ON m.clan_id = p.clan_id AND m.mt = p.unix_time",
(season_start, season_end),
)
board = [(cid, sc) for cid, sc in cur.fetchall() if sc is not None]
if not board:
return None
board.sort(key=lambda r: r[1], reverse=True)
total = len(board)
for i, (cid, _sc) in enumerate(board):
if cid == clan_id:
return i + 1, total
return None
@dataclass
class MatchRow:
session_id: str
@@ -1070,6 +1107,7 @@ def render_squadron_card(out_path: Path, ident: SquadronIdent, season: str,
rolling_battles: List[Tuple[int, int]],
season_start: int, season_end: int,
week_boundaries: List[int],
place: Optional[Tuple[int, int]] = None,
theme: str = "light",
lang: str = DEFAULT_LANG) -> None:
pal = theme_palette(theme)
@@ -1219,7 +1257,7 @@ def render_squadron_card(out_path: Path, ident: SquadronIdent, season: str,
# Short rows render in 2 columns; wide rows span full width.
short_rows: List[Tuple[str, str]] = [
(t("imgStatPeakRating"), f"{_fmt_int(rating.peak)} ({_fmt_date(rating.peak_ts)})"),
(t("imgStatRatingChange"), f"{'+' if (rating.change or 0) >= 0 else ''}{_fmt_int(rating.change)}"),
(t("imgStatPlaceFinished"), f"#{_fmt_int(place[0])} / {_fmt_int(place[1])}" if place else ""),
(t("imgStatTotalKills"), f"{_fmt_int(players.total_kills)} ({_fmt_int(players.ground_kills)} {t('imgGroundShort')} / {_fmt_int(players.air_kills)} {t('imgAirShort')})"),
(t("imgStatTotalDeaths"), _fmt_int(players.deaths)),
(t("imgStatAssistsCaptures"), f"{_fmt_int(players.assists)} / {_fmt_int(players.captures)}"),
@@ -1677,6 +1715,8 @@ def run_squadron(args: Args) -> int:
logging.info(f"resolved {ident.short_name} / {ident.long_name}")
rating = gather_squadron_rating(conn_sq, args.clan_id, args.season_start, args.season_end)
logging.info(f"rating: series={len(rating.series)} final={rating.final} peak={rating.peak} change={rating.change}")
place = gather_squadron_place(conn_sq, args.clan_id, args.season_start, args.season_end)
logging.info(f"place: {place}")
with _open_ro(SQ_BATTLES_DB) as conn_b:
stream = gather_squadron_match_stream(conn_b, ident.short_name, args.season_start, args.season_end)
@@ -1707,6 +1747,7 @@ def run_squadron(args: Args) -> int:
rolling_wr, rolling_kd, rolling_battles,
args.season_start, args.season_end,
args.week_boundaries,
place=place,
theme=args.theme,
lang=args.lang,
)
+39 -14
View File
@@ -3185,6 +3185,10 @@ def _unit_to_model_name(unit_name: str) -> str:
internal = (unit_name or "").strip()
if not internal:
return "tankModels/unknown"
if internal.startswith(("tankModels/", "airModels/")):
return internal
if internal.startswith("aircrafts/"):
return f"airModels/{internal.split('/', 1)[1]}"
tags = _get_unit_tags(internal) or []
tag_set = set(tags)
if "type_strike_ucav" in tag_set or "ucav" in internal.lower():
@@ -3245,6 +3249,15 @@ def _position_at_time(path: list[dict[str, float]], time_ms: float) -> dict[str,
return prev
def _event_position_to_dict(pos: Any) -> dict[str, float] | None:
if not isinstance(pos, list) or len(pos) < 3:
return None
try:
return {"X": float(pos[0]), "Y": float(pos[1]), "Z": float(pos[2])}
except (TypeError, ValueError):
return None
def _zone_geometry_from_raw_zones(zones_src: Any) -> dict[str, dict]:
if not isinstance(zones_src, dict):
return {}
@@ -3307,7 +3320,7 @@ def _convert_ws_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, Any]:
if not isinstance(ent, dict):
continue
uid = _to_int(ent.get("uid"), 0)
unit = str(ent.get("unit") or "")
unit = str(ent.get("model_path") or ent.get("unit") or "")
path_raw = ent.get("path") or []
if not isinstance(path_raw, list):
continue
@@ -3349,16 +3362,22 @@ def _convert_ws_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, Any]:
kill_time = float(kill.get("time") or 0.0)
victim_path = entity_paths_by_uid.get(victim_id, [])
killer_path = entity_paths_by_uid.get(killer_id, [])
victim_pos = _position_at_time(victim_path, kill_time)
killer_pos = _position_at_time(killer_path, kill_time)
victim_pos = (
_position_at_time(victim_path, kill_time)
or _event_position_to_dict(kill.get("offended_pos"))
)
killer_pos = (
_position_at_time(killer_path, kill_time)
or _event_position_to_dict(kill.get("offender_pos"))
)
payload: dict[str, Any] = {
"Time": kill_time,
"VictimID": victim_id,
"KillerID": killer_id,
"VictimEntityIndex": uid_to_entity_index.get(victim_id, 0),
"Weapon": str(kill.get("used_weapon") or kill.get("weapon") or ""),
"VictimModel": _unit_to_model_name(str(kill.get("offended_unit") or "")),
"KillerModel": _unit_to_model_name(str(kill.get("offender_unit") or "")),
"VictimModel": _unit_to_model_name(str(kill.get("offended_unit_model_path") or kill.get("offended_unit") or "")),
"KillerModel": _unit_to_model_name(str(kill.get("offender_unit_model_path") or kill.get("offender_unit") or "")),
"crashed": bool(kill.get("crashed", False)),
}
if victim_pos:
@@ -3383,8 +3402,8 @@ def _convert_ws_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, Any]:
"Time": float(dmg.get("time") or 0.0),
"OffenderID": _to_int(dmg.get("offender_uid"), 0),
"OffendedID": _to_int(dmg.get("offended_uid"), 0),
"OffenderModel": _unit_to_model_name(str(dmg.get("offender_unit") or "")),
"OffendedModel": _unit_to_model_name(str(dmg.get("offended_unit") or "")),
"OffenderModel": _unit_to_model_name(str(dmg.get("offender_unit_model_path") or dmg.get("offender_unit") or "")),
"OffendedModel": _unit_to_model_name(str(dmg.get("offended_unit_model_path") or dmg.get("offended_unit") or "")),
"Afire": bool(dmg.get("afire", False)),
})
@@ -3468,7 +3487,7 @@ def _convert_local_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, An
if not isinstance(ent, dict):
continue
uid = _to_int(ent.get("uid"), 0)
unit = str(ent.get("unit") or "")
unit = str(ent.get("model_path") or ent.get("unit") or "")
path_raw = ent.get("path") or []
if not isinstance(path_raw, list):
continue
@@ -3510,16 +3529,22 @@ def _convert_local_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, An
kill_time = float(kill.get("time") or 0.0)
victim_path = entity_paths_by_uid.get(victim_id, [])
killer_path = entity_paths_by_uid.get(killer_id, [])
victim_pos = _position_at_time(victim_path, kill_time)
killer_pos = _position_at_time(killer_path, kill_time)
victim_pos = (
_position_at_time(victim_path, kill_time)
or _event_position_to_dict(kill.get("offended_pos"))
)
killer_pos = (
_position_at_time(killer_path, kill_time)
or _event_position_to_dict(kill.get("offender_pos"))
)
payload: dict[str, Any] = {
"Time": kill_time,
"VictimID": victim_id,
"KillerID": killer_id,
"VictimEntityIndex": uid_to_entity_index.get(victim_id, 0),
"Weapon": str(kill.get("used_weapon") or kill.get("weapon") or ""),
"VictimModel": _unit_to_model_name(str(kill.get("offended_unit") or "")),
"KillerModel": _unit_to_model_name(str(kill.get("offender_unit") or "")),
"VictimModel": _unit_to_model_name(str(kill.get("offended_unit_model_path") or kill.get("offended_unit") or "")),
"KillerModel": _unit_to_model_name(str(kill.get("offender_unit_model_path") or kill.get("offender_unit") or "")),
"crashed": bool(kill.get("crashed", False)),
}
if victim_pos:
@@ -3544,8 +3569,8 @@ def _convert_local_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, An
"Time": float(dmg.get("time") or 0.0),
"OffenderID": _to_int(dmg.get("offender_uid"), 0),
"OffendedID": _to_int(dmg.get("offended_uid"), 0),
"OffenderModel": _unit_to_model_name(str(dmg.get("offender_unit") or "")),
"OffendedModel": _unit_to_model_name(str(dmg.get("offended_unit") or "")),
"OffenderModel": _unit_to_model_name(str(dmg.get("offender_unit_model_path") or dmg.get("offender_unit") or "")),
"OffendedModel": _unit_to_model_name(str(dmg.get("offended_unit_model_path") or dmg.get("offended_unit") or "")),
"Afire": bool(dmg.get("afire", False)),
})
+26 -2
View File
@@ -41,6 +41,7 @@ from data_parser import (
apply_vehicle_name_filters,
normalize_name,
)
from spectra_replay_normalize import normalize_spectra_replay
load_dotenv()
@@ -597,6 +598,15 @@ def gate_entitle(required_tier: str):
async def permission_fail(interaction: discord.Interaction, error):
"""Handle permission-related errors with appropriate embeds."""
# discord.py dispatches a command error to BOTH the command's local
# `@cmd.error` handler and the global `@tree.error` handler (see
# app_commands/tree.py). Since both route here, guard against sending the
# error embed twice for the same interaction — the second send would land
# as a separate (and, after a public defer, differently-scoped) message.
if interaction.extras.get("_permission_fail_handled"):
return
interaction.extras["_permission_fail_handled"] = True
lang = await guild_lang(interaction.guild_id) if interaction.guild_id else "en"
if isinstance(error, BlacklistCheckFailure):
reason = getattr(error, "reason", None)
@@ -1251,7 +1261,7 @@ def transform_to_local_format(api_data: Dict[str, Any]) -> Optional[Dict[str, An
logging.error("Invalid API data structure")
return None
replay = api_data["completed"][0]
replay = normalize_spectra_replay(api_data["completed"][0])
winner_winged = str(replay.get("winner") or "")
loser_winged = str(replay.get("loser") or "")
@@ -2151,14 +2161,19 @@ async def _get_recap(
try:
stat = cache_path.stat()
if season_range["status"] == "completed":
serve_from_cache = True
# A completed season's card is only final if it was rendered AFTER
# the season ended. Files rendered mid-season are stale snapshots
# frozen at whatever point they were last viewed — re-render those.
serve_from_cache = stat.st_mtime > season_range["end"]
elif (time.time() - stat.st_mtime) < RECAP_TTL_SECONDS:
serve_from_cache = True
except FileNotFoundError:
pass
ident = clan_id if mode == "squadron" else uid
if not serve_from_cache:
cache_dir.mkdir(parents=True, exist_ok=True)
t0 = time.monotonic()
await _spawn_recap_render(
mode,
clan_id=clan_id,
@@ -2171,6 +2186,15 @@ async def _get_recap(
theme=theme,
lang=lang,
)
logging.info(
"(RECAP) %s id=%s season=%s theme=%s lang=%s result=fresh ms=%d",
mode, ident, season, theme, lang, (time.monotonic() - t0) * 1000,
)
else:
logging.info(
"(RECAP) %s id=%s season=%s theme=%s lang=%s result=cache",
mode, ident, season, theme, lang,
)
if not cache_path.exists():
raise RecapError("recap file missing after render")
+27 -4
View File
@@ -9,18 +9,33 @@ const DEPLOY_PATH = __dirname;
// Both bots share one venv at BOTS/SHARED/.venv (built from SHARED/requirements.txt).
const PY_INTERPRETER = `${DEPLOY_PATH}/../SHARED/.venv/bin/python`;
// Shared crash-loop governor. Without this, `autorestart` relaunches a process
// that dies on startup forever (every restart_delay). Several apps here share
// SHARED/.env + SHARED/.venv + the STORAGE volume, so one bad shared config can
// make them crash-loop at once and peg all 8 cores until the box is unreachable
// (and `pm2 resurrect` then reproduces it on every boot). With this, PM2 gives
// up after max_restarts attempts that each fail to stay up min_uptime ms,
// marking the app `errored` instead of hammering the CPU. exp_backoff grows the
// delay between attempts (supersedes restart_delay during a crash loop).
const RESTART_POLICY = {
max_restarts: 10,
min_uptime: 10000,
exp_backoff_restart_delay: 200,
};
module.exports = {
apps: [
// Discord Bot
{
name: 'srebot',
...RESTART_POLICY,
script: 'start_bot.py',
interpreter: PY_INTERPRETER,
cwd: DEPLOY_PATH,
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '16000M',
max_memory_restart: '12000M',
log_file: './logs/bot_combined.log',
out_file: './logs/bot_out.log',
error_file: './logs/bot_error.log',
@@ -33,9 +48,13 @@ module.exports = {
// API Server (reads SREBOT_API_PORT from .env)
{
name: 'srebot-api',
script: 'server.js',
interpreter: 'node',
node_args: '--max-old-space-size=6144',
...RESTART_POLICY,
// Shell wrapper exports UV_THREADPOOL_SIZE at the OS level.
// PM2's env: and env_file options don't propagate to the child
// process's OS environ block (required by libuv for threadpool init).
script: 'start_server.sh',
interpreter: 'none',
node_args: [],
cwd: DEPLOY_PATH,
instances: 1,
autorestart: true,
@@ -54,6 +73,7 @@ module.exports = {
// Reads TSS_API_HOST/PORT from .env (default 127.0.0.1:6100).
{
name: 'tssbot-api',
...RESTART_POLICY,
script: PY_INTERPRETER,
args: '-m web.main',
interpreter: 'none',
@@ -79,6 +99,7 @@ module.exports = {
// Reads SREBOT_EXTERNAL_HOST/PORT/UPSTREAM_URL + STORAGE_VOL_PATH from .env.
{
name: 'relay-gateway',
...RESTART_POLICY,
script: PY_INTERPRETER,
args: '-m relay_gateway.gateway',
interpreter: 'none',
@@ -100,6 +121,7 @@ module.exports = {
// Reads SREBOT_WEBHOOK_PORT from .env.
{
name: 'srebot-webhook',
...RESTART_POLICY,
script: 'github_webhook_updater.py',
interpreter: PY_INTERPRETER,
cwd: DEPLOY_PATH,
@@ -119,6 +141,7 @@ module.exports = {
// Website (reads SREBOT_WEB_PORT from .env)
{
name: 'srebot-web',
...RESTART_POLICY,
script: 'server.js',
cwd: `${DEPLOY_PATH}/web`,
instances: 3,
+18
View File
@@ -127,6 +127,24 @@ def pull_and_restart(changed_files: list[str], before: str = '', after: str = ''
processes_to_restart.append('srebot-api')
logger.info("API server.js changed, will restart srebot-api")
# Relay gateway: restart if SHARED/relay_gateway/ files changed
relay_gateway_changed = any(
f.startswith('SHARED/relay_gateway/')
for f in changed_files
)
if relay_gateway_changed:
processes_to_restart.append('relay-gateway')
logger.info("Relay gateway files changed, will restart relay-gateway")
# TSSBOT API: restart if TSSBOT/web/ files changed
tssbot_api_changed = any(
f.startswith('TSSBOT/web/')
for f in changed_files
)
if tssbot_api_changed:
processes_to_restart.append('tssbot-api')
logger.info("TSSBOT API files changed, will restart tssbot-api")
# Web frontend: restart if SREBOT/web/ files changed
web_changed = any(f.startswith('SREBOT/web/') for f in changed_files)
if web_changed:
+117 -70
View File
@@ -6,6 +6,7 @@ const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const seasonsUtil = require('./web/utils/seasons');
const http = require('http');
/** Parse a JSON column that may be gzip-compressed (Buffer) or plain text (string). */
function parseJsonColumn(data) {
@@ -88,13 +89,7 @@ function requireAdminBearer(req, res, next) {
app.use('/api', requireApiBearer);
// Rewrite /api/sre/* → /api/* so the gateway can forward channel-prefixed paths.
app.use((req, res, next) => {
if (req.url.startsWith('/api/sre')) {
req.url = '/api' + req.url.slice(9);
}
next();
});
// Readiness gate: heavy aggregation endpoints sit behind this so cold-start
// requests don't pile up on the read connection while the DB is still opening
@@ -192,6 +187,91 @@ function dedup(key, worker) {
return promise;
}
// ─── Heavy-aggregate cache (leaderboards) ───────────────────────────────
// Leaderboard payloads cost 13-53 s to build (full season-window scans on the
// shared heavyDb connection). Give them their own cache -- isolated from the
// 100-entry responseCache churn -- with stale-while-revalidate serving + a
// background warmer so requests never block on a cold/stale leaderboard.
const aggregateCache = new Map();
const aggregateInFlight = new Map();
const LIVE_AGG_TTL = 5 * 60 * 1000;
const COMPLETED_AGG_TTL = 24 * 60 * 60 * 1000;
function aggregateCacheTtl(dateFilters) {
const now = Math.floor(Date.now() / 1000);
if (dateFilters && dateFilters.endTimestamp && dateFilters.endTimestamp < now - 24 * 3600) {
return COMPLETED_AGG_TTL;
}
return LIVE_AGG_TTL;
}
function aggregateKey(prefix, dateFilters) {
return `${prefix}_${dateFilters.startTimestamp ?? 'all'}_${dateFilters.endTimestamp ?? 'all'}`;
}
function refreshAggregate(key, compute) {
const existing = aggregateInFlight.get(key);
if (existing) return existing;
const promise = Promise.resolve().then(compute)
.then(data => { aggregateCache.set(key, { data, timestamp: Date.now() }); return data; })
.finally(() => aggregateInFlight.delete(key));
aggregateInFlight.set(key, promise);
return promise;
}
function serveAggregateCached(key, dateFilters, compute, res, shape, opts = {}) {
const entry = aggregateCache.get(key);
if (entry) {
const fresh = Date.now() - entry.timestamp < aggregateCacheTtl(dateFilters);
res.json(shape(entry.data));
if (!fresh) refreshAggregate(key, compute).catch(err =>
log.warn('Background aggregate refresh failed', { key, error: err && err.message }));
return;
}
if (opts.allowCompute === false) {
return res.status(400).json({
error: opts.filterError || 'A date filter (start_date/end_date/season/week) is required.',
errorCode: 'FILTER_REQUIRED',
});
}
refreshAggregate(key, compute)
.then(data => { if (!res.headersSent) res.json(shape(data)); })
.catch(err => {
if (res.headersSent) return;
if (err && err.status && err.body) return res.status(err.status).json(err.body);
log.error('Aggregate computation failed', err, { key });
res.status(500).json({ error: 'Database error', errorCode: opts.errorCode || 'DB_LEADERBOARD_FAILED' });
});
}
// ─── Leaderboard warmer (Fix 3) ─────────────────────────────────────────
function warmOneAggregate(routePath, startTs, endTs) {
const path = `${routePath}?start_date=${startTs}&end_date=${endTs}&limit=1`;
const req = http.get({ host: '127.0.0.1', port: PORT, path }, r => { r.resume(); });
req.on('error', err => log.warn('Leaderboard warm request failed', { path, error: err.message }));
req.setTimeout(120000, () => req.destroy());
}
function warmLeaderboards() {
let seasons;
try { seasons = seasonsUtil.getSeasons(); } catch (e) {
return log.warn('Warmer: getSeasons failed', { error: e.message });
}
const entries = Object.entries(seasons)
.map(([name, r]) => ({ name, start: r.start, end: r.end, status: r.status }))
.sort((a, b) => a.start - b.start);
const current = entries.filter(s => s.status === 'in_progress');
const completed = entries.filter(s => s.status === 'completed');
const lastCompleted = completed.length ? [completed[completed.length - 1]] : [];
const targets = [...current, ...lastCompleted];
for (const s of targets) {
warmOneAggregate('/api/leaderboard/players', s.start, s.end);
warmOneAggregate('/api/leaderboard/vehicles', s.start, s.end);
warmOneAggregate('/api/leaderboard/squadrons', s.start, s.end);
}
log.info('Leaderboard warmer tick', { windows: targets.map(t => t.name) });
}
// Best-effort sync lookup of a squadron name → clan_id using the warm cache.
// Returns null if the cache isn't populated yet or the input doesn't resolve.
// Callers fall back to text matching when this returns null.
@@ -3490,22 +3570,9 @@ app.get('/api/leaderboard/players', (req, res) => {
return { ...full, players: full.players.slice(0, limit), limit, returned: Math.min(limit, full.players.length) };
};
const cacheKey = `leaderboard_players_${start_date || 'all'}_${end_date || 'all'}`;
const cached = getCachedResponse(cacheKey);
if (cached) {
log.info('Returning cached leaderboard response');
return res.json(applyLimit(cached));
}
const cacheKey = aggregateKey('leaderboard_players', dateFilters);
if (!dateFilters.hasFilter) {
return res.status(400).json({
error: 'A date filter (start_date/end_date/season/week) is required for uncached all-time player leaderboard queries.',
errorCode: 'FILTER_REQUIRED'
});
}
// Dedup: if this exact query is already running, wait for it
dedup(cacheKey, () => new Promise((resolve, reject) => {
const compute = () => new Promise((resolve, reject) => {
// Step 1: Pure aggregation — no nick lookups, single scan
const statsQuery = `
SELECT
@@ -3537,8 +3604,7 @@ app.get('/api/leaderboard/players', (req, res) => {
heavyDb.all(statsQuery, queryParams, (err, statsRows) => {
if (err) {
log.error('Database error in player leaderboard aggregation', err);
reject(err);
return res.status(500).json({ error: 'Database error', errorCode: 'DB_PLAYER_LEADERBOARD_FAILED' });
return reject(err);
}
log.info('Player leaderboard aggregation done', { rows: statsRows.length, ms: Date.now() - queryStart });
@@ -3595,9 +3661,7 @@ app.get('/api/leaderboard/players', (req, res) => {
totalMs: Date.now() - queryStart
});
setCachedResponse(cacheKey, response);
resolve(response);
res.json(applyLimit(response));
};
// Fallback for players not in any squadron — uses (UID, endtime_unix) index
@@ -3620,10 +3684,12 @@ app.get('/api/leaderboard/players', (req, res) => {
});
});
});
})).catch(err => {
if (!res.headersSent) {
res.status(500).json({ error: 'Database error', errorCode: 'DB_PLAYER_LEADERBOARD_FAILED' });
}
});
serveAggregateCached(cacheKey, dateFilters, compute, res, applyLimit, {
allowCompute: dateFilters.hasFilter,
filterError: 'A date filter (start_date/end_date/season/week) is required for uncached all-time player leaderboard queries.',
errorCode: 'DB_PLAYER_LEADERBOARD_FAILED',
});
});
@@ -3647,21 +3713,9 @@ app.get('/api/leaderboard/vehicles', (req, res) => {
return { ...full, vehicles: full.vehicles.slice(0, limit), limit, returned: Math.min(limit, full.vehicles.length) };
};
const cacheKey = `leaderboard_vehicles_${vehicle || 'all'}_${start_date || 'all'}_${end_date || 'all'}`;
const cached = getCachedResponse(cacheKey);
if (cached) {
log.info('Returning cached vehicle leaderboard');
return res.json(applyLimit(cached));
}
const cacheKey = `leaderboard_vehicles_${vehicle || 'all'}_${dateFilters.startTimestamp ?? 'all'}_${dateFilters.endTimestamp ?? 'all'}`;
if (!vehicle && !dateFilters.hasFilter) {
return res.status(400).json({
error: 'A date filter (start_date/end_date/season/week) or vehicle filter is required for uncached all-time vehicle leaderboard queries.',
errorCode: 'FILTER_REQUIRED'
});
}
dedup(cacheKey, () => new Promise((resolve, reject) => {
const compute = () => new Promise((resolve, reject) => {
let statsQuery;
let queryParams;
@@ -3703,8 +3757,7 @@ app.get('/api/leaderboard/vehicles', (req, res) => {
heavyDb.all(statsQuery, queryParams, (err, vehicleRows) => {
if (err) {
log.error('Database error in vehicle leaderboard', err);
reject(err);
return res.status(500).json({ error: 'Database error', errorCode: 'DB_VEHICLE_LEADERBOARD_FAILED' });
return reject(err);
}
// Nick/squadron lookup from squadron_members cache (instant)
@@ -3751,9 +3804,7 @@ app.get('/api/leaderboard/vehicles', (req, res) => {
};
log.info('Vehicle leaderboard complete', { vehiclesReturned: vehicleRows.length, totalMs: Date.now() - queryStart });
setCachedResponse(cacheKey, response);
resolve(response);
res.json(applyLimit(response));
};
if (uncoveredUids.length > 0) {
@@ -3774,10 +3825,12 @@ app.get('/api/leaderboard/vehicles', (req, res) => {
});
});
});
})).catch(err => {
if (!res.headersSent) {
res.status(500).json({ error: 'Database error', errorCode: 'DB_VEHICLE_LEADERBOARD_FAILED' });
}
});
serveAggregateCached(cacheKey, dateFilters, compute, res, applyLimit, {
allowCompute: Boolean(vehicle) || dateFilters.hasFilter,
filterError: 'A date filter (start_date/end_date/season/week) or vehicle filter is required for uncached all-time vehicle leaderboard queries.',
errorCode: 'DB_VEHICLE_LEADERBOARD_FAILED',
});
});
@@ -3802,14 +3855,9 @@ app.get('/api/leaderboard/squadrons', (req, res) => {
};
};
const cacheKey = `leaderboard_squadrons_${start_date || 'all'}_${end_date || 'all'}`;
const cached = getCachedResponse(cacheKey);
if (cached) {
log.info('Returning cached squadron leaderboard');
return res.json(applyLimit(cached));
}
const cacheKey = aggregateKey('leaderboard_squadrons', dateFilters);
dedup(cacheKey, () => new Promise((resolve, reject) => {
const compute = () => new Promise((resolve, reject) => {
const schemaQuery = `PRAGMA table_info(player_games_hist)`;
db.all(schemaQuery, (err, schema) => {
@@ -3830,7 +3878,6 @@ app.get('/api/leaderboard/squadrons', (req, res) => {
migration_needed: true,
message: 'Squadron data not available - squadron_name column missing from database'
};
setCachedResponse(cacheKey, emptyResponse);
return resolve(emptyResponse);
}
@@ -4063,22 +4110,17 @@ app.get('/api/leaderboard/squadrons', (req, res) => {
squadronsWithPoints: squadronsArray.filter(s => s.points.has_points_data).length
});
setCachedResponse(cacheKey, response);
resolve(response);
});
});
});
});
});
})).then(response => {
if (!res.headersSent) res.json(applyLimit(response));
}).catch(err => {
if (res.headersSent) return;
if (err && err.status && err.body) {
return res.status(err.status).json(err.body);
}
log.error('Unhandled error in squadron leaderboard', err);
res.status(500).json({ error: 'Database error occurred', errorCode: 'DB_SQUADRON_LEADERBOARD_FAILED' });
});
serveAggregateCached(cacheKey, dateFilters, compute, res, applyLimit, {
allowCompute: true,
errorCode: 'DB_SQUADRON_LEADERBOARD_FAILED',
});
});
@@ -6039,6 +6081,11 @@ setTimeout(() => {
}, 600000); // Every 10 minutes
}, 150000); // Start after 2.5 min offset
// Warm hot leaderboard windows shortly after boot, then every 4 min.
// The first tick waits for the DB-ready grace so heavy scans don't fight startup.
setTimeout(warmLeaderboards, 20000);
setInterval(warmLeaderboards, 4 * 60 * 1000);
app.listen(PORT, () => {
console.log(`SREBOT Player API server running on port ${PORT}`);
console.log(`Health check: http://localhost:${PORT}/health`);
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Wrapper so libuv reads UV_THREADPOOL_SIZE from the OS environ block.
# PM2's env:/env_file don't propagate to child processes on this system.
export UV_THREADPOOL_SIZE=24
exec node --max-old-space-size=6144 server.js
+13 -1
View File
@@ -32,4 +32,16 @@ week 5 (29.05 — 04.06) <t:1780038000:R> max BR 9.0
week 6 (05.06 — 11.06) <t:1780642800:R> max BR 8.0
week 7 (12.06 — 18.06) <t:1781247600:R> max BR 7.0
week 8 (19.06 — 25.06) <t:1781852400:R> max BR 6.0
until eos (26.06 — 30.06) <t:1782457200:R> max BR 5.0
until eos (26.06 — 30.06) <t:1782457200:R> max BR 5.0
2026-IV
week 1 (01.07 — 07.07) <t:1782889200:R> max BR 14.7
week 2 (08.07 — 14.07) <t:1783494000:R> max BR 12.0
week 3 (15.07 — 21.07) <t:1784098800:R> max BR 10.7
week 4 (22.07 — 28.07) <t:1784703600:R> max BR 9.7
week 5 (29.07 — 04.08) <t:1785308400:R> max BR 8.7
week 6 (05.08 — 11.08) <t:1785913200:R> max BR 7.3
week 7 (12.08 — 18.08) <t:1786518000:R> max BR 6.3
week 8 (19.08 — 25.08) <t:1787122800:R> max BR 5.7
until eos (26.08 — 31.08) <t:1787727600:R> max BR 4.7
+1
View File
@@ -702,6 +702,7 @@
"imgAxisWinRate": "Win Rate (%)",
"imgStatPeakRating": "Peak rating",
"imgStatRatingChange": "Rating change",
"imgStatPlaceFinished": "Place finished",
"imgStatTotalKills": "Total kills",
"imgStatTotalDeaths": "Total deaths",
"imgStatAssistsCaptures": "Assists / captures",
+25 -20
View File
@@ -509,31 +509,30 @@ async function getCachedData(type) {
return cache.data;
}
// Initialize cache on startup — staggered to avoid overloading SQLite
// Initialize only lightweight caches on the primary worker.
// Player and vehicle leaderboards are too expensive to refresh in the background:
// recent production runs took 140s+ and kept the API CPU saturated after callers timed out.
async function initializeCache() {
log.info('[CACHE] Initializing leaderboard cache (staggered)...');
// Stats is lightest, do it first
if (!IS_PRIMARY_WORKER) return;
log.info('[CACHE] Initializing lightweight leaderboard cache (staggered)...');
await updateCache('stats');
log.info('[CACHE] Stats cache ready');
// Squadrons next
await updateCache('squadrons');
log.info('[CACHE] Squadrons cache ready');
// Players is heaviest
await updateCache('players');
log.info('[CACHE] Players cache ready');
// Vehicles last
await updateCache('vehicles');
log.info('[CACHE] Vehicles cache ready — all caches populated!');
}
// Auto-refresh caches staggered to avoid hammering the API all at once
const cacheTypes = ['players', 'vehicles', 'stats', 'squadrons'];
cacheTypes.forEach((type, i) => {
setInterval(async () => {
log.debug(`[CACHE] Auto-refreshing ${type} cache...`);
await updateCache(type).catch(log.error);
}, CACHE_DURATION + i * 15000); // stagger each by 15s
});
// Auto-refresh only lightweight caches from the primary worker. Heavy caches are
// refreshed lazily by request so background work cannot pin SQLite indefinitely.
if (IS_PRIMARY_WORKER) {
const cacheTypes = ['stats', 'squadrons'];
cacheTypes.forEach((type, i) => {
setInterval(async () => {
log.debug(`[CACHE] Auto-refreshing ${type} cache...`);
await updateCache(type).catch(log.error);
}, CACHE_DURATION + i * 15000);
});
}
// Log search cache statistics every 10 minutes
setInterval(() => {
@@ -1045,7 +1044,10 @@ app.get('/squadron/:clan_id/recap/:season.png', async (req, res) => {
try {
const stat = await fsp.stat(cachePath);
if (range.status === 'completed') {
serveFromCache = true;
// Only trust a completed-season cache if it was rendered after the
// season ended; mid-season files are stale snapshots (range.end is
// epoch seconds, mtimeMs is ms).
serveFromCache = stat.mtimeMs > range.end * 1000;
} else if (Date.now() - stat.mtimeMs < RECAP_TTL_MS) {
serveFromCache = true;
}
@@ -1113,7 +1115,10 @@ app.get('/players/:uid/recap/:season.png', async (req, res) => {
try {
const stat = await fsp.stat(cachePath);
if (range.status === 'completed') {
serveFromCache = true;
// Only trust a completed-season cache if it was rendered after the
// season ended; mid-season files are stale snapshots (range.end is
// epoch seconds, mtimeMs is ms).
serveFromCache = stat.mtimeMs > range.end * 1000;
} else if (Date.now() - stat.mtimeMs < RECAP_TTL_MS) {
serveFromCache = true;
}