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

418 lines
26 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.
Actualizacion TASK-225, 2026-06-10: la deuda P1 de `stats search` y `stats player profile` fue corregida en codigo para que los GET publicos usen `player_search_index` y `player_period_stats` en modo read-only estricto, sin inicializar storage ni caer a runtime fallback pesado. La medicion HTTP final requiere redeploy. `current-match` sigue pendiente porque depende de RCON live y debe tratarse como hardening/degradacion sin cambiar hosts, puertos ni configuracion RCON.
Actualizacion TASK-226, 2026-06-10: `/api/current-match/kills` y `/api/current-match/players` pasan a usar AdminLog en lectura publica sin inicializar storage y con degradacion JSON controlada si el read model no esta disponible o falla. `/api/servers` pasa a ser snapshot/cache-only en el GET publico y ya no dispara refresh RCON/A2S live durante la lectura. La deuda restante de `current-match` queda en `/api/current-match`, que todavia puede intentar una muestra RCON directa antes de caer a snapshot.
Actualizacion TASK-227, 2026-06-10: la auditoria real post-`TASK-226` mostro que kills/players seguian bloqueando en produccion porque la rama PostgreSQL de AdminLog no propagaba `ensure_storage=False` a `connect_postgres_compat()`. El fix propaga `initialize=ensure_storage`, de modo que `/api/current-match/kills` y `/api/current-match/players` ya no ejecutan `initialize_postgres_rcon_storage()` en el GET publico cuando se sirven como lecturas read-only.
Actualizacion TASK-228, 2026-06-10: `/api/servers` deja de ser cache-only estricto y vuelve a ser near-real-time controlado para la home. Sirve snapshot fresco si existe; si no hay cache o esta stale, intenta RCON/A2S con timeout publico corto y degrada a snapshot stale o JSON controlado si live falla. `/api/servers/latest` e `/api/servers/history` siguen siendo lecturas de almacenamiento local, no sustitutos del estado live.
Actualizacion TASK-229, 2026-06-10: los endpoints legacy agregados `/api/historical/server-summary?server=all-servers` y `/api/historical/recent-matches?server=all-servers&limit=20` dejan de ejecutar read model RCON o fallback runtime CRCON/PostgreSQL en lectura publica. Ahora son wrappers de los snapshots precomputados equivalentes, con `legacy_endpoint_policy=snapshot-read-only-fast-path`; si falta snapshot devuelven JSON controlado rapido en vez de agotar el timeout.
Actualizacion TASK-230, 2026-06-10: el ultimo `CRITICAL` de la auditoria global fue `/api/historical/server-summary?server=comunidad-hispana-01`, con HTTP 200, `fallback=False` y ~10.1 s. La causa estaba en el path legacy por servidor, que seguia entrando en el read model RCON y enriquecia el resumen con actividad materializada reciente. `server-summary` legacy pasa a usar snapshot fast-path para cualquier `server=` explicito (`all-servers`, `comunidad-hispana-01`, `comunidad-hispana-02`), manteniendo contrato compatible con `items` y metadata de snapshot.
Actualizacion TASK-231, 2026-06-10: los dos `CRITICAL` restantes fueron `/api/historical/recent-matches?server=comunidad-hispana-01&limit=20` y `/api/historical/recent-matches?server=comunidad-hispana-02&limit=20`, ambos timeout ~30 s. El fast-path de `TASK-229` solo cubria `all-servers`; los scopes individuales seguian entrando en RCON/materialized recent activity y podian caer al fallback legacy. `recent-matches` legacy pasa a usar snapshot fast-path para cualquier `server=` explicito y respeta el `limit` recibido.
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. Tras TASK-227, kills/players usan AdminLog PostgreSQL en modo read-only real y degradan desde backend en JSON controlado.
- 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. `/api/servers` tambien consulta live de forma controlada cuando falta cache o esta stale porque la home requiere estado actual/casi actual.
## 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: `/api/current-match` consulta RCON directo en la ruta publica cuando encuentra target valido. Kills/players leen AdminLog sin inicializar storage en request publico, incluyendo la rama PostgreSQL corregida en `TASK-227`. `/api/servers` usa refresh live acotado cuando el cache no sirve para mantener la home casi en tiempo real.
## 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 | Snapshot precomputado read-only | No | P2 |
| `/api/historical/recent-matches?server=<scope>` | legacy historico audit | `build_recent_historical_matches_payload()` | Snapshot publico de recent matches | Wrapper legacy sobre snapshot precomputado para cualquier `server=` explicito | No | P2 |
| `/api/historical/server-summary?server=<scope>` | legacy historico audit | `build_historical_server_summary_payload()` | Snapshot publico de server summary | Wrapper legacy sobre snapshot precomputado para cualquier `server=` explicito | No | 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 | AdminLog materializado en modo read-only publico; PostgreSQL usa `connect_postgres_compat(initialize=False)` | Degradacion JSON controlada si falla read model | P2 |
| `/api/current-match/players` | `frontend/assets/js/partida-actual.js` | `build_current_match_player_stats_payload()` | Read model live propio de player stats | AdminLog materializado en modo read-only publico; PostgreSQL usa `connect_postgres_compat(initialize=False)` | Degradacion JSON controlada si falla read model | P2 |
| `/api/servers` | `frontend/assets/js/main.js`, fallback de current-match | `build_servers_payload()` | Snapshot live de servidores | Snapshot fresco o refresh live RCON/A2S acotado si falta/stale | Stale snapshot o JSON controlado si live falla | P1 |
| `/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.