diff --git a/ai/tasks/done/TASK-223-optimize-historical-match-detail-payload.md b/ai/tasks/done/TASK-223-optimize-historical-match-detail-payload.md new file mode 100644 index 0000000..c0f131d --- /dev/null +++ b/ai/tasks/done/TASK-223-optimize-historical-match-detail-payload.md @@ -0,0 +1,183 @@ +--- +id: TASK-223 +title: Optimize historical match detail payload +status: done +type: backend +team: Backend Senior +supporting_teams: + - Frontend Senior +roadmap_item: foundation +priority: high +--- + +# TASK-223 - Optimize historical match detail payload + +## Goal + +Corregir la lentitud extrema de `/api/historical/matches/detail` para la partida historica RCON `comunidad-hispana-01:1781023156:1781028555:purpleheartlanewarfare`, que tarda alrededor de 55 segundos dentro del backend con 140 jugadores y provoca timeout/error publico. + +## Context + +La evidencia indica que el payload no es grande: unos 150 KB para 140 jugadores. La lentitud se produce durante la construccion del detalle, probablemente por enriquecimiento costoso o patron N+1 de perfiles/jugadores. + +Preserve the current product identity: Spanish-speaking HLL Vietnam community, military/Vietnam/tactical/sober visual direction and controlled repository evolution. + +## Steps + +1. Perfilar `build_historical_match_detail_payload()` o `get_rcon_historical_match_detail()` para la partida problematica antes de cambiar codigo. +2. Identificar funciones lentas y confirmar si `profile_summary`, enlaces externos, `top_weapons`, `most_killed`, `death_by`, victims o nemesis generan trabajo por jugador. +3. Optimizar el detalle inicial para que sirva metadata, marcador y lista basica de jugadores sin enriquecimiento historico global pesado. +4. Mantener en el payload inicial las estadisticas de la partida que ya vengan del read model: kills, deaths, teamkills, KD, armas, abatidos y muertes por rival si no requieren queries extra. +5. Si `profile_summary` resulta costoso, quitarlo o hacerlo opcional en el detalle inicial y dejarlo para endpoint diferido futuro. +6. Confirmar que `historico-partida.js` sigue funcionando sin `profile_summary`. +7. Validar backend, frontend si se toca, endpoint interno y endpoint publico cuando sea posible. + +## Files to Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/backend-senior.md` +- `backend/app/rcon_historical_read_model.py` +- `backend/app/payloads.py` +- `frontend/assets/js/historico-partida.js` +- `ai/tasks/done/TASK-222-fix-historical-match-detail-backend-url-and-false-kpm.md` +- `docs/PERFORMANCE_PUBLIC_QUERY_AUDIT.md` + +## Expected Files to Modify + +- `backend/app/rcon_historical_read_model.py` +- `backend/tests/test_rcon_materialization_pipeline.py` +- `ai/tasks/done/TASK-223-optimize-historical-match-detail-payload.md` + +## Constraints + +- Keep the change minimal. +- Preserve HLL Vietnam project identity. +- Do not introduce unnecessary frameworks or dependencies. +- Do not run `ai-platform run`. +- Do not push. +- Do not touch weapon assets. +- Do not touch SVGs. +- Do not modify physical images. +- Do not touch `ai/system-metrics.md`. +- Do not reactivate Elo/MMR. +- Do not reintroduce Comunidad Hispana #03. +- Do not include unrelated previous changes. +- Do not introduce runtime RCON or scoreboard queries in the public request. +- Do not reintroduce false KPM. + +## Validation + +Before completing the task ensure: + +- Profiling output before and after the backend change is recorded in the outcome. +- `python -m compileall backend/app` if backend is touched. +- Relevant backend tests run, or a small focused test is added if coverage is missing. +- `node --check frontend/assets/js/historico-partida.js` if frontend is touched. +- The problematic internal endpoint is measured and returns under 1 second if the local environment can reach the same data. +- The public endpoint is checked for 200/found=true if publicly reachable from the current environment. +- Visual validation confirms the detail page renders marcador, mapa and players and does not show KPM if a browser target is available. +- `git diff --name-only` matches expected scope, except pre-existing unrelated changes. +- Confirm excluded files/assets were not touched. + +## Outcome + +Archivos modificados por esta task: + +- `backend/app/rcon_historical_read_model.py` +- `backend/tests/test_rcon_materialization_pipeline.py` +- `ai/tasks/done/TASK-223-optimize-historical-match-detail-payload.md` + +Causa encontrada: + +- En el path RCON materializado, `_build_materialized_detail_item()` cargaba siempre `get_latest_rcon_player_profile_summaries()` para todos los jugadores de la partida antes de devolver el detalle inicial. +- Ese enriquecimiento no pertenece al detalle basico de partida: trae historico global de perfil por jugador (`sessions`, `matches_played`, `favorite_weapons`, `victims`, `nemesis`, `averages`, `sanctions`) y parsea esos JSON para cada perfil devuelto. +- No se encontro N+1 en Python para `top_weapons`, `most_killed` o `death_by`: salen de JSON ya materializado en `rcon_match_player_stats` para esa partida. +- `external_profile_links` es barato: se deriva localmente desde `player_id` con `build_external_player_profile_fields()`, sin consulta extra. +- El patron lento confirmado por codigo queda en `profile_summary`, no en el tamano del payload ni en la renderizacion frontend. + +Profiling ejecutado: + +- Comando de perfil solicitado contra `build_historical_match_detail_payload()` para `comunidad-hispana-01:1781023156:1781028555:purpleheartlanewarfare`. +- En este entorno no estaba configurada la misma base PostgreSQL de produccion y Docker no estaba disponible (`docker compose ps` no pudo conectar con Docker Desktop), por lo que el perfil local cayo a fallback SQLite con `found=false`. +- Antes del cambio, perfil local disponible: + - `seconds`: 5.333 + - `found`: false + - `players`: 0 + - `source`: `historical-crcon-storage` + - `fallback_used`: true + - cuello local no representativo: `historical_storage.initialize_historical_storage()` y normalizacion SQLite legacy. +- Despues del cambio, perfil local disponible: + - `seconds`: 3.644 + - `found`: false + - `players`: 0 + - `source`: `historical-crcon-storage` + - `fallback_used`: true + - sigue sin ser medicion representativa de la partida RCON porque no alcanza el read model real. +- Evidencia inicial de produccion recibida para el caso real: + - `seconds`: 55.137 + - `http`: 200 + - `bytes`: 150813 + - `players`: 140 + - `source`: `rcon-historical-competitive-read-model` + - `fallback_used`: false + +Solucion aplicada: + +- El detalle inicial RCON ya no llama a `get_latest_rcon_player_profile_summaries()`. +- `_build_player_row()` ahora devuelve solo datos propios de la partida: + - `player_name` + - `team` + - `kills` + - `deaths` + - `teamkills` + - `kd_ratio` + - `top_weapons` + - `most_killed` + - `death_by` + - `external_profile_links` derivados localmente +- `profile_summary` queda diferido para una futura ruta lazy por jugador si se necesita mostrar historico global al expandir una fila. + +Frontend: + +- No se modifico `frontend/assets/js/historico-partida.js`. +- La UI ya no dependia de `profile_summary`; renderiza tabla y panel expandible con datos de partida, enlaces externos y mensajes `No disponible` cuando faltan listas ampliadas. +- No se reintrodujo KPM. +- La llamada relativa `/api/historical/matches/detail` se mantiene intacta. + +Validaciones ejecutadas: + +- `python -m compileall backend/app` +- `python -m unittest tests.test_rcon_materialization_pipeline.RconMaterializationPipelineTests.test_match_detail_omits_profile_summary_when_snapshot_exists` +- `python -m unittest tests.test_rcon_materialization_pipeline` +- Perfil local con `cProfile` antes y despues, documentado arriba. +- `Invoke-WebRequest` contra `http://127.0.0.1:8000/api/historical/matches/detail?...` fallo con `No es posible conectar con el servidor remoto`; no habia backend local escuchando. +- `docker compose ps` fallo porque Docker Desktop no estaba disponible en esta sesion. +- `git diff --name-only` revisado. + +Resultado de validaciones: + +- `compileall` paso. +- La suite `tests.test_rcon_materialization_pipeline` paso: 9 tests OK. +- La suite emite `ResourceWarning` de conexiones SQLite no cerradas ya existentes en esos tests, pero no falla. +- No se pudo validar el endpoint publico real ni visualmente por falta de URL publica absoluta/backend activo en este entorno. + +Confirmacion de exclusiones: + +- No se ejecuto `ai-platform run`. +- No se hizo push. +- No se hizo commit. +- No se tocaron assets de armas. +- No se tocaron SVGs. +- No se modificaron imagenes fisicas. +- No se toco `ai/system-metrics.md`. +- No se reactivo Elo/MMR. +- No se reintrodujo Comunidad Hispana #03. +- No se toco backend de rankings/snapshots. +- No se incluyeron cambios previos no relacionados. `git status` muestra cambios preexistentes en `ai/system-metrics.md`, assets de armas/SVGs y otros archivos no relacionados; esta task los dejo intactos. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split the work into follow-up tasks if limits are exceeded. diff --git a/backend/app/rcon_historical_read_model.py b/backend/app/rcon_historical_read_model.py index 9434ba7..bb986dd 100644 --- a/backend/app/rcon_historical_read_model.py +++ b/backend/app/rcon_historical_read_model.py @@ -253,21 +253,9 @@ def _build_materialized_recent_item(item: dict[str, object]) -> dict[str, object def _build_materialized_detail_item(materialized: dict[str, object]) -> dict[str, object]: - from .rcon_admin_log_storage import get_latest_rcon_player_profile_summaries - match = materialized["match"] recent_item = _build_materialized_recent_item(match) - profile_summaries = get_latest_rcon_player_profile_summaries( - target_key=str(match["target_key"]), - player_ids=[str(row["player_id"]) for row in materialized["players"] if row.get("player_id")], - ) - players = [ - _build_player_row( - row, - profile_summary=profile_summaries.get(str(row.get("player_id"))), - ) - for row in materialized["players"] - ] + players = [_build_player_row(row) for row in materialized["players"]] player_count = len(players) if players else recent_item.get("player_count") return { **recent_item, @@ -298,12 +286,10 @@ def _resolve_materialized_player_count(item: dict[str, object]) -> int | None: def _build_player_row( row: dict[str, object], - *, - profile_summary: dict[str, object] | None = None, ) -> dict[str, object]: kills = _coerce_optional_int(row.get("kills")) or 0 deaths = _coerce_optional_int(row.get("deaths")) or 0 - player = { + return { "player_name": row.get("player_name"), "team": row.get("team"), "kills": kills, @@ -315,9 +301,6 @@ def _build_player_row( "death_by": _top_counter(row.get("death_by_json")), **build_external_player_profile_fields(player_id=row.get("player_id")), } - if profile_summary: - player["profile_summary"] = profile_summary - return player def _top_counter(raw_value: object, *, limit: int = 5) -> list[dict[str, object]]: diff --git a/backend/tests/test_rcon_materialization_pipeline.py b/backend/tests/test_rcon_materialization_pipeline.py index 6e1b9cf..33c72bc 100644 --- a/backend/tests/test_rcon_materialization_pipeline.py +++ b/backend/tests/test_rcon_materialization_pipeline.py @@ -85,7 +85,7 @@ class RconMaterializationPipelineTests(unittest.TestCase): ) self.assertEqual(players["Charlie"]["platform"], "unknown") self.assertNotIn("steam_id_64", players["Charlie"]) - self.assertNotIn("external_profile_links", players["Charlie"]) + self.assertEqual(players["Charlie"]["external_profile_links"], {}) gc.collect() def test_match_detail_marks_equal_materialized_timestamps_as_server_time_only(self) -> None: @@ -181,7 +181,7 @@ class RconMaterializationPipelineTests(unittest.TestCase): ) gc.collect() - def test_match_detail_adds_safe_profile_summary_when_snapshot_exists(self) -> None: + def test_match_detail_omits_profile_summary_when_snapshot_exists(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: db_path = Path(tmpdir) / "historical.sqlite3" previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH") @@ -222,15 +222,11 @@ class RconMaterializationPipelineTests(unittest.TestCase): self.assertIsNotNone(detail) players = {row["player_name"]: row for row in detail["players"]} - self.assertIn("profile_summary", players["Alpha"]) + self.assertNotIn("profile_summary", players["Alpha"]) self.assertNotIn("profile_summary", players["Bravo"]) - profile_summary = players["Alpha"]["profile_summary"] - self.assertEqual(profile_summary["sessions"], 12) - self.assertEqual(profile_summary["matches_played"], 9) - self.assertEqual(profile_summary["totals"]["kills"], 141) - self.assertEqual(profile_summary["favorite_weapons"], {"M1 Garand": 31}) - self.assertNotIn("raw_content", profile_summary) self.assertNotIn("player_id", players["Alpha"]) + self.assertEqual(players["Alpha"]["steam_id_64"], "76561198000000001") + self.assertIn("external_profile_links", players["Alpha"]) gc.collect() def test_recent_matches_prefer_materialized_rcon_over_scoreboard_fallback(self) -> None: