Files
comunidadhll/docs/PERFORMANCE_PUBLIC_QUERY_AUDIT.md
2026-06-10 09:19:54 +02:00

403 lines
21 KiB
Markdown

# Performance Public Query Audit
## Resumen Ejecutivo
Esta auditoria revisa las rutas publicas que alimentan `ranking`, `stats`, `historico`, `historico-partida`, `partida-actual` e `index`, con foco en latencia percibida por UI y en el cumplimiento de la regla arquitectonica de leer read models publicos propios en PostgreSQL.
Conclusiones principales:
- El backend de `ranking` ya no muestra el cuello de botella grave del ranking anual. La evidencia mas fuerte es el test `backend/tests/test_annual_ranking_payload.py`, que confirma que la lectura anual en PostgreSQL ya no inicializa storage en request publico.
- El cuello de botella visible actual mas claro esta en frontend: `frontend/assets/js/ranking.js` y `frontend/assets/js/stats.js` bloquean la carga principal detras de `/health`, y `ranking.js` no tiene proteccion contra request race ni limpieza robusta del estado de carga.
- `historico.js` es hoy la referencia mas sana del frontend publico: usa snapshots, cache TTL, deduplicacion de peticiones y `requestId` para ignorar respuestas obsoletas.
- `partida-actual.js` no bloquea por `/health`, pero hace polling agresivo y paralelo a tres endpoints (`/api/current-match`, `/api/current-match/kills`, `/api/current-match/players`) sin `AbortController`, con intervalos de 1.5 s y 3 s que pueden amplificar carga y re-render innecesario.
- En backend siguen existiendo fallbacks runtime publicos sobre tablas materializadas grandes para `ranking`, `stats search` y `stats player profile`. Son mejores que consultar RCON directo, pero siguen rompiendo la meta de servir lecturas publicas desde read models dedicados.
- Las queries runtime de leaderboard y player stats usan patrones como `COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT))` y agregaciones sobre `rcon_match_player_stats`, lo que aumenta riesgo de scans y de uso parcial de indices.
- `current-match` sigue consultando RCON directo en request publico cuando hay target confiable. Eso contradice la regla objetivo de esta auditoria y debe tratarse como deuda arquitectonica explicita aunque hoy sea un requisito funcional de la pagina live.
## Mapa de Arquitectura de Lectura Publica
Flujo esperado:
1. Procesos internos leen RCON, AdminLog o scoreboard publico cuando aplica.
2. Procesos internos refrescan snapshots y read models en PostgreSQL.
3. Endpoints publicos leen solo read models publicos, sin recalculo runtime ni inicializacion de storage.
4. Frontend pide datos principales sin bloquearlos por checks tecnicos no esenciales.
Flujo observado:
- `ranking` y `stats` mezclan frontend secuencial con backend que aun puede caer a runtime sobre tablas materializadas si falta snapshot o read model.
- `historico` ya prioriza snapshots y fallback controlado.
- `current-match` expone una excepcion relevante: consulta RCON directo en la ruta publica cuando encuentra target valido.
## Inventario de Endpoints Publicos
| Ruta | Frontend | Builder backend | Read model esperado | Lectura observada | Fallback/runtime | Riesgo |
|---|---|---|---|---|---|---|
| `/api/ranking` | `frontend/assets/js/ranking.js` | `build_global_ranking_payload()` | `ranking_snapshots`, `ranking_snapshot_items`, `rcon_annual_ranking_snapshots`, `rcon_annual_ranking_snapshot_items` | Annual lee snapshot anual; weekly/monthly leen snapshot y pueden caer a `list_rcon_materialized_leaderboard()` | Si `HLL_BACKEND_RANKING_RUNTIME_FALLBACK_ENABLED=true`, si | P0 |
| `/api/stats/players/search` | `frontend/assets/js/stats.js` | `build_stats_player_search_payload()` | `player_search_index` | Primero `player_search_index`, si falla cae a runtime contra `rcon_match_player_stats` + `rcon_materialized_matches` | Si | P1 |
| `/api/stats/players/{player_id}` | `frontend/assets/js/stats.js` | `build_stats_player_profile_payload()` | `player_period_stats` | Primero `player_period_stats`, si falta cae a runtime y ademas calcula weekly/monthly ranking en caliente | Si | P1 |
| `/api/stats/rankings/annual` | `frontend/assets/js/stats.js` | `build_annual_ranking_snapshot_payload()` | `rcon_annual_ranking_snapshots`, `rcon_annual_ranking_snapshot_items` | Snapshot anual | No recalcula ranking en lectura | P2 |
| `/api/historical/snapshots/leaderboard` | `frontend/assets/js/historico.js` | `build_rcon_materialized_leaderboard_snapshot_payload()` en modo rcon | Snapshot historico publico equivalente | En modo rcon el nombre dice snapshot, pero sirve runtime fast path sobre materialized leaderboard | Si, por definicion del endpoint en modo rcon | P1 |
| `/api/historical/snapshots/recent-matches` | `frontend/assets/js/historico.js` | `build_recent_historical_matches_snapshot_payload()` | Snapshot publico de recent matches | Segun source kind puede usar RCON read model o fallback publico | Si | P2 |
| `/api/historical/matches/detail` | `frontend/assets/js/historico-partida.js` | `build_historical_match_detail_payload()` | Read model detalle de partida | Intenta `get_rcon_historical_match_detail()`, luego fallback a storage historico publico | Si | P2 |
| `/api/current-match` | `frontend/assets/js/partida-actual.js` | `build_current_match_payload()` | Read model live propio | Primero intenta `_query_current_match_rcon_sample()` directo; luego fallback a `/api/servers` snapshot | Si, y toca RCON directo | P0 |
| `/api/current-match/kills` | `frontend/assets/js/partida-actual.js` | `build_current_match_kill_feed_payload()` | Read model live propio de kill feed | `list_current_match_kill_feed()` desde evidencia AdminLog materializada | No se observo fallback a scoreboard | P1 |
| `/api/current-match/players` | `frontend/assets/js/partida-actual.js` | `build_current_match_player_stats_payload()` | Read model live propio de player stats | `list_current_match_player_stats()` desde evidencia AdminLog materializada | No se observo fallback a scoreboard | P1 |
| `/api/servers` | `frontend/assets/js/main.js`, fallback de current-match | `build_servers_payload()` | Snapshot live de servidores | Snapshot local/live payload | Sin fallback costoso visible | P2 |
| `/health` | `frontend/assets/js/main.js`, `ranking.js`, `stats.js` | `build_health_payload()` | N/A | Check tecnico | No aplica | P1 por bloqueo UI, no por backend |
Notas:
- El endpoint equivalente real al pedido como `/api/current-match/player-stats` es `/api/current-match/players`.
- El equivalente real de `historico` no es una sola ruta `/api/historico`; el frontend consume varias rutas bajo `/api/historical/...`.
## Analisis Frontend
### Ranking
Archivo: `frontend/assets/js/ranking.js`
Hallazgos:
- La carga inicial depende de `refreshBackendHealth()` y solo despues llama `loadRanking()`.
- Si `/health` tarda, falla o llega fuera de orden respecto a cambios del usuario, la UI puede permanecer en `Cargando ranking global...` o en estado offline aunque `/api/ranking` sea rapido.
- No hay `AbortController` ni `currentRequestId`.
- Cada cambio de filtro dispara `loadRanking()` sin proteccion contra respuestas antiguas.
- No hay `finally` dedicado para limpiar loading o reactivar controles.
- `clearRankingSurface()` vacia tabla y meta antes de cada request, amplificando el parpadeo de UI.
Impacto:
- Alto en latencia percibida.
- Alto en riesgo de estado obsoleto.
### Stats
Archivo: `frontend/assets/js/stats.js`
Hallazgos:
- Repite el patron de esperar `/health` antes de cargar el ranking anual.
- `searchPlayers()` hace una sola request, pero cualquier error marca backend offline globalmente.
- `loadPlayerProfile()` usa `Promise.allSettled()` para semanal y mensual, lo cual es correcto, pero sigue dependiendo del flag global `isBackendOnline`.
- No hay cancelacion de busquedas sucesivas ni de perfiles sucesivos.
Impacto:
- Alto en UX.
- Medio en carga backend.
### Historico
Archivo: `frontend/assets/js/historico.js`
Fortalezas observadas:
- Usa `activeServerRequestId` y `activeLeaderboardRequestId`.
- Cachea snapshots con TTL.
- Deduplica peticiones en `pendingRequestCache`.
- Hidrata desde cache y luego refresca.
- No bloquea la carga principal detras de `/health`.
Riesgos residuales:
- Mucho uso de `innerHTML` completo en bloques grandes.
- El endpoint llamado como `snapshots/leaderboard` en modo rcon puede ser runtime fast path, lo que hace que la UI se vea sana aunque backend no este sirviendo un snapshot real.
### Historico Partida
Archivo: `frontend/assets/js/historico-partida.js`
Hallazgos:
- Hace una sola request principal de detalle.
- No hay `/health` previo.
- El costo fuerte parece mas de render DOM que de orchestration.
Riesgo:
- Bajo a medio.
### Partida Actual
Archivo: `frontend/assets/js/partida-actual.js`
Hallazgos:
- Polling en tres loops independientes:
- current match cada 30 s
- kills cada 1.5 s
- players cada 3 s
- Hay guardas `*_RefreshInFlight`, pero no `AbortController`.
- Si el usuario abandona la pagina sin descargar el documento, el polling sigue hasta destruir el contexto.
- `current-match` re-renderiza bloques completos con `innerHTML`.
- `kill feed` y `player stats` reducen re-render con `visibleSignature`, lo cual ayuda.
Riesgo:
- Alto para carga sostenida.
- Alto si la base live comparte recursos con lecturas publicas.
### Landing
Archivo: `frontend/assets/js/main.js`
Hallazgos:
- `fetchHealth()` es paralelo a `hydrateTrailer()` y `refreshServers()`, no bloqueante.
- El polling de servidores es cada 300 s y tiene guardas `serverRefreshInFlight`.
Riesgo:
- Bajo.
## Analisis Backend
### Confirmaciones positivas
- `backend/tests/test_annual_ranking_payload.py` confirma que `get_annual_ranking_snapshot()` no llama `initialize_rcon_materialized_storage()` en lectura PostgreSQL. Ese fix elimina el cuello de botella mas grave ya conocido del ranking anual.
- `routes.py` separa parseo/validacion de parametros y builders por endpoint de forma clara.
- `main.py` ya serializa `date` y `datetime` para no abortar respuestas por JSON.
### Riesgos de backend por area
#### Ranking publico
Archivos: `backend/app/payloads.py`, `backend/app/rcon_historical_leaderboards.py`, `backend/app/rcon_annual_rankings.py`
Hallazgos:
- Weekly/monthly `build_global_ranking_payload()` puede caer a `list_rcon_materialized_leaderboard()` si falta snapshot y el flag runtime fallback sigue activo.
- `list_rcon_materialized_leaderboard()` hace agregacion runtime sobre `rcon_match_player_stats` + `rcon_materialized_matches`.
- Las ventanas usan filtros sobre `COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT))`, lo que puede degradar indices y elevar costo.
Riesgo:
- El endpoint publico sigue siendo rapido hoy en muchos casos, pero no esta totalmente blindado contra crecimiento de datos.
#### Stats search
Archivo: `backend/app/rcon_historical_player_stats.py`
Hallazgos:
- `search_rcon_materialized_players()` usa `player_search_index` primero, bien.
- Si el read model falla o falta, cae a runtime contra tablas grandes.
- La busqueda runtime usa `LOWER(COALESCE(stats.player_name,'')) LIKE LOWER(?)`, agregacion por jugador y luego lookups adicionales de nombres y `servers_seen`.
Riesgo:
- Posible scan costoso y plan poco estable al crecer el historico.
#### Stats player profile
Archivo: `backend/app/rcon_historical_player_stats.py`
Hallazgos:
- `get_rcon_materialized_player_stats()` usa `player_period_stats` primero, bien.
- Si falta, cae a runtime y ademas consulta ranking semanal y mensual del jugador contra tablas materializadas.
- La ruta runtime agrega sobre stats, busca source range y ranking semanal/mensual en la misma lectura.
Riesgo:
- Latencia alta en cold path o con crecimiento de tabla.
#### Historical endpoints
Archivo: `backend/app/payloads.py`
Hallazgos:
- `build_recent_historical_matches_payload()` y `build_historical_match_detail_payload()` permiten fallback a storage publico cuando el read model RCON no cubre el caso.
- El test `test_public_scoreboard_fallback_used_only_without_rcon_activity` confirma que el fallback sigue activo y esperado.
Riesgo:
- Correcto como compatibilidad, pero dificulta garantizar tiempos uniformes y pureza de read model publico.
#### Current match
Archivo: `backend/app/payloads.py`
Hallazgos:
- `build_current_match_payload()` intenta `_query_current_match_rcon_sample()` en request publico.
- Solo si falla usa snapshot de `/api/servers`.
Riesgo:
- Este es el incumplimiento arquitectonico mas claro: request publico consultando RCON directo.
#### Conexiones y cold path
Archivos: `backend/app/postgres_rcon_storage.py`, `backend/app/config.py`
Hallazgos:
- Se usa `psycopg.connect(...)` por contexto, no se observa pool reutilizable.
- No se observan migrations/initializers pesados dentro de rutas publicas, salvo funciones de read model que aun llaman wrappers de initialize en algunas rutas runtime.
Riesgo:
- Medio. La ausencia de pooling puede no ser critica hoy, pero empeora cold starts y bursts cortos.
## Analisis PostgreSQL / Read Models
### Tablas publicas esperadas
- `player_search_index`
- `player_period_stats`
- `ranking_snapshots`
- `ranking_snapshot_items`
- `rcon_annual_ranking_snapshots`
- `rcon_annual_ranking_snapshot_items`
### Tablas grandes o candidatas a crecimiento
- `rcon_materialized_matches`
- `rcon_match_player_stats`
### Indices confirmados por codigo
- `idx_ranking_snapshots_lookup`
- `idx_ranking_snapshot_items_snapshot`
- `idx_ranking_snapshot_items_player`
- `idx_rcon_annual_ranking_snapshots_year`
- `idx_rcon_annual_ranking_snapshots_status`
- `idx_player_search_index_name`
- `idx_player_search_index_last_seen`
- `idx_player_search_index_player`
- `idx_player_period_stats_player_period_server`
- `idx_player_period_stats_server_period`
- `idx_player_period_stats_last_seen`
- `idx_player_period_stats_updated`
- `idx_rcon_materialized_matches_recent`
- indices textuales sobre `COALESCE(CAST(ended_at AS TEXT), CAST(started_at AS TEXT))`
- `idx_rcon_match_player_stats_match`
- `idx_rcon_match_player_stats_player_id_match`
### Riesgos de SQL
- Varios paths runtime filtran por `COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) >= ?`.
- Ese patron tiende a forzar expresiones calculadas en filtro, incluso aunque existan indices funcionales; es un area clara para `EXPLAIN ANALYZE`.
- `LIKE` sobre `LOWER(player_name)` puede degradarse sin estrategia de indice orientada a busqueda parcial.
- Agregaciones publicas sobre `COUNT(DISTINCT stats.match_key)` y `SUM(...)` en tablas grandes deben salir de snapshots o read models, no del path web.
### Queries candidatas a EXPLAIN ANALYZE
- Runtime fallback de `list_rcon_materialized_leaderboard()`
- Runtime fallback de `_search_rcon_materialized_players_runtime()`
- Runtime fallback de `_get_rcon_materialized_player_stats_runtime()`
- Lectura de `get_latest_ranking_snapshot()`
- Lectura de `get_annual_ranking_snapshot()`
- Lectura de `player_search_index` por `server_id + normalized_player_name`
- Lectura de `player_period_stats` por `player_id + period_type + server_id`
### Locks y refresh
- Los runners usan `backend_writer_lock` para procesos internos, lo cual ayuda a writers.
- Aun asi, refreshes completos de snapshots y read models pueden competir por IO con lecturas publicas si comparten la misma base y no se mide el impacto.
- La auditoria no encontro evidencia directa de locks de lectura publica, pero si suficiente motivo para instrumentarlos.
## Riesgos de Fallback / Runtime
Principio deseado:
- Publico: solo read models publicos.
- Interno: materializacion y recalculo.
Desviaciones observadas:
- `/api/ranking` weekly/monthly puede recalcular agregados runtime sobre tablas materializadas.
- `/api/stats/players/search` puede escanear runtime si falta `player_search_index`.
- `/api/stats/players/{player_id}` puede recalcular runtime si falta `player_period_stats`.
- `/api/current-match` consulta RCON directo en request publico.
- `historico` snapshot en modo rcon puede servir runtime fast path en lugar de snapshot real.
## Observabilidad Recomendada
Medicion backend por request:
- `endpoint`
- `status_code`
- `total_duration_ms`
- `db_duration_ms`
- `payload_build_duration_ms`
- `query_count`
- `read_model`
- `fallback_used`
- `fallback_reason`
- `snapshot_status`
- `payload_bytes`
- `timeframe`
- `server_id`
- `metric`
- `limit`
Medicion frontend por vista:
- `page`
- `request_started_at`
- `response_received_at`
- `render_completed_at`
- `perceived_latency_ms`
- `health_blocked_main_data` boolean
- `request_aborted`
- `request_superseded`
- `render_error`
- `stale_response_ignored`
Puntos concretos:
- `performance.mark()` / `performance.measure()` en `ranking.js`, `stats.js`, `partida-actual.js`
- Logging estructurado por endpoint en `main.py` o wrapper de builders
- Incluir `read_model`, `fallback_used`, `snapshot_status` y `payload_bytes` en la respuesta o en logs
- Contador de queries por request en paths publicos costosos
## Matriz de Hallazgos Priorizados
| Pri | Hallazgo | Impacto | Evidencia | Archivo | Propuesta | Riesgo | Validacion |
|---|---|---|---|---|---|---|---|
| P0 | `ranking.js` bloquea la carga principal detras de `/health` y no protege carreras | UI lenta o bloqueada aunque `/api/ranking` responda en <200 ms | Flujo `refreshBackendHealth() -> loadRanking()` y ausencia de `AbortController/currentRequestId` | `frontend/assets/js/ranking.js` | Separar health del dato principal y usar cancelacion o requestId | Bajo | medir `response_received_at -> render_completed_at` |
| P0 | `/api/current-match` consulta RCON directo en request publico | Rompe la regla arquitectonica y puede introducir latencia o fragilidad externa | `_query_current_match_rcon_sample()` se ejecuta antes del fallback a snapshot | `backend/app/payloads.py` | Crear read model live publico y mover la consulta RCON al runner | Medio | endpoint debe seguir respondiendo sin tocar RCON |
| P1 | `/api/ranking` weekly/monthly mantiene fallback runtime sobre tablas materializadas | Riesgo de latencia creciente y scans | `build_global_ranking_payload()` cae a `list_rcon_materialized_leaderboard()` | `backend/app/payloads.py` | Desactivar fallback runtime en publico tras completar snapshot coverage | Medio | requests solo con `read_model=ranking-snapshot` |
| P1 | `stats.js` tambien bloquea por `/health` el ranking anual inicial | Latencia percibida innecesaria | `refreshBackendHealth()` llama `loadAnnualRanking()` despues de `/health` | `frontend/assets/js/stats.js` | Cargar anual directo y tratar `/health` como señal secundaria | Bajo | anual visible sin depender de health |
| P1 | Search de jugadores puede caer a runtime costoso | Picos de latencia en busqueda | `search_rcon_materialized_players()` con fallback runtime | `backend/app/rcon_historical_player_stats.py` | Endurecer cobertura de `player_search_index` y alertar si falta | Bajo | `fallback_used=false` sostenido |
| P1 | Perfil de jugador puede caer a runtime y recomponer rankings semanales/mensuales | Respuestas lentas y carga de DB | `get_rcon_materialized_player_stats()` | `backend/app/rcon_historical_player_stats.py` | Exigir `player_period_stats` actualizado antes de publicar | Medio | `read_model=player-period-stats` |
| P1 | Endpoints snapshot historicos en modo rcon no siempre son snapshots reales | Ambiguedad operativa y mediciones engañosas | `build_rcon_materialized_leaderboard_snapshot_payload()` declara snapshot pero usa runtime materialized fast path | `backend/app/rcon_historical_leaderboards.py` | Renombrar politica o servir snapshot real | Medio | source/generation policy coherentes |
| P2 | Polling de current match demasiado agresivo | Carga sostenida y re-render frecuente | intervalos de 1.5 s / 3 s / 30 s | `frontend/assets/js/partida-actual.js` | Consolidar polling o usar fan-out backend/cache | Medio | bajar requests por minuto |
| P2 | Paths runtime usan `COALESCE(CAST(... AS TEXT))` en filtros temporales | Planes menos eficientes | multiples queries runtime en leaderboard y player stats | `backend/app/rcon_historical_leaderboards.py`, `backend/app/rcon_historical_player_stats.py` | Revisar predicados e indices funcionales con EXPLAIN | Medio | comparar buffers/scan time |
| P3 | No se observa pooling PostgreSQL reutilizable | Mayor costo de cold path y bursts | `psycopg.connect()` por contexto | `backend/app/postgres_rcon_storage.py` | Evaluar pool ligero cuando el resto del path este estabilizado | Medio | medir connect time y throughput |
## Plan de Tasks Recomendado
1. `TASK-215-decouple-public-frontend-data-load-from-health-checks`
Alcance: `ranking.js`, `stats.js`.
Objetivo: no bloquear datos principales por `/health`.
2. `TASK-216-add-request-race-protection-to-public-ranking-and-stats`
Alcance: `ranking.js`, `stats.js`.
Objetivo: `AbortController` o `currentRequestId`, cleanup robusto de loading.
3. `TASK-217-enforce-snapshot-only-public-ranking-read-path`
Alcance: backend publico de `ranking`.
Objetivo: eliminar fallback runtime en `/api/ranking` para weekly/monthly.
4. `TASK-218-enforce-read-model-only-public-player-search-and-profile`
Alcance: `player_search_index`, `player_period_stats`, runners, payloads.
Objetivo: que search y profile no caigan a runtime en request publico.
5. `TASK-219-create-live-public-current-match-read-model`
Alcance: current match.
Objetivo: sacar RCON directo del endpoint publico.
6. `TASK-220-add-public-request-performance-observability`
Alcance: backend + frontend instrumentation minima.
Objetivo: medir request, DB, payload, render y fallback.
7. `TASK-221-run-explain-analyze-for-public-runtime-and-read-model-queries`
Alcance: SQL audit operativa.
Objetivo: validar indices y predicados temporales.