diff --git a/ai/tasks/done/TASK-226-harden-current-match-and-servers-public-endpoints.md b/ai/tasks/done/TASK-226-harden-current-match-and-servers-public-endpoints.md new file mode 100644 index 0000000..c8b6fa3 --- /dev/null +++ b/ai/tasks/done/TASK-226-harden-current-match-and-servers-public-endpoints.md @@ -0,0 +1,136 @@ +--- +id: TASK-226 +title: Harden current-match and servers public endpoints +status: done +type: backend +team: Backend Senior +supporting_teams: + - Analista + - Arquitecto Python +roadmap_item: foundation +priority: high +--- + +# TASK-226 - Harden current-match and servers public endpoints + +## Goal + +Harden the public endpoints still affected by RCON/live dependencies after `TASK-225`: + +- `/api/current-match/kills` +- `/api/current-match/players` +- `/api/servers` + +The objective is not to redesign RCON connectivity. Public HTTP reads must not hang indefinitely or return uncontrolled 500 responses when live RCON/AdminLog data is slow, unavailable or missing. + +## Context + +`TASK-225` stabilized: + +- `/api/stats/players/search` +- `/api/stats/players/{player_id}` +- `/api/historical/matches/detail` + +Production validation after `TASK-225` showed those endpoints responding OK without fallback. Remaining audit debt was RCON/live: + +- `/api/current-match/kills` timed out or returned 500. +- `/api/current-match/players` timed out for `comunidad-hispana-01`. +- `/api/servers` could spend about 4.3s refreshing RCON from a public GET. + +RCON timeout in the current test environment must not be interpreted automatically as wrong configuration. Backend/RCON `127.0.0.1`, hosts, ports and `27001` may be correct in final production and must not be changed in this task. + +## Steps + +1. Inspect public route and payload chains. +2. Make current-match AdminLog reads public read-only and controlled. +3. Make `/api/servers` return configuration/cache/snapshot data without live refresh in the request. +4. Add focused tests for controlled degradation and no public live refresh. +5. Validate and document the outcome. + +## Files to Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/backend-senior.md` +- `backend/app/routes.py` +- `backend/app/payloads.py` +- `backend/app/rcon_admin_log_storage.py` +- `backend/app/rcon_client.py` +- `backend/app/providers/rcon_provider.py` +- `docs/FULL_APPLICATION_REQUEST_AUDIT.md` +- `docs/PERFORMANCE_PUBLIC_QUERY_AUDIT.md` + +## Expected Files to Modify + +- `backend/app/payloads.py` +- `backend/app/rcon_admin_log_storage.py` +- `backend/tests/test_current_match_payload.py` +- `docs/FULL_APPLICATION_REQUEST_AUDIT.md` +- `docs/PERFORMANCE_PUBLIC_QUERY_AUDIT.md` +- `ai/tasks/done/TASK-226-harden-current-match-and-servers-public-endpoints.md` + +## Constraints + +- Do not run `ai-platform run`. +- Do not commit or push. +- Do not touch RCON hosts, ports, credentials, environment variables, Docker networking or server connection configuration. +- Do not change `27001`. +- Do not replace backend/RCON `127.0.0.1`; it may be correct in production. +- Do not touch frontend assets, weapon assets, clan assets, SVGs or physical images. +- Do not modify `frontend/assets/img/weapons/` or `frontend/assets/img/clans/`. +- Do not modify `ai/system-metrics.md`. +- Do not reactivate Elo/MMR. +- Do not reintroduce Comunidad Hispana #03. + +## Validation + +Required: + +- `python -m compileall backend/app` +- `python -m unittest tests.test_current_match_payload` +- current-match related tests if present +- payload related tests if present +- partial production audit if possible: + - `python scripts/audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --filter current-match --output tmp/task226_current_match_audit.json` + - `python scripts/audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --filter servers --output tmp/task226_servers_audit.json` + +## Outcome + +Implemented: + +- `/api/current-match/kills` now calls AdminLog storage in read-only mode with `ensure_storage=False`. If the AdminLog read model is missing, slow or raises, the public payload returns `status: ok`, `items: []`, `confidence: unavailable`, and source metadata with `fallback_used: true` and an `admin-log-*` reason. +- `/api/current-match/players` uses the same read-only/degrade pattern and returns an empty controlled current-match player stats payload instead of propagating storage exceptions as uncontrolled 500s. +- `rcon_admin_log_storage` gained read-only SQLite opening for current-match public reads so missing local storage does not create files or initialize schemas during GET handling. +- `/api/servers` no longer calls `_try_collect_real_time_snapshot()` from the public payload builder. It serves persisted snapshots only, marks stale/fresh state, and returns `refresh_status: "cache-only"`. +- Focused unit tests were added under `backend/tests/test_current_match_payload.py` for kill feed degradation, player stats degradation, and ensuring `/api/servers` does not trigger live refresh in a public GET. + +Not changed: + +- RCON hosts, ports, credentials, environment variables and server configuration. +- `27001`. +- Frontend JS, because existing `partida-actual.js` catches endpoint errors and clears loading states for kills/players. +- Assets, SVGs and physical images. +- Elo/MMR and Comunidad Hispana #03. + +Validation performed: + +- `python -m compileall backend/app`: OK. +- `cd backend; python -m unittest tests.test_current_match_payload`: OK, 3 tests from `CurrentMatchPublicEndpointHardeningTests`. +- Related current-match tests: no previous test modules were present in this repo checkout. +- Related payload tests: no previous payload-specific test modules were present in this repo checkout. +- `python scripts/audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --filter current-match --output tmp/task226_current_match_audit.json`: executed against current production deployment. It still shows the pre-deploy failures: kills CH01 timeout 30054.96 ms, players CH01 timeout 30024.09 ms, kills CH02 timeout 30068.39 ms, players CH02 500 in 7497.46 ms. Re-run after deployment is required. +- `python scripts/audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --filter servers --output tmp/task226_servers_audit.json`: executed against current production deployment. `/api/servers` still measured 200 in 4249.71 ms, consistent with pre-deploy live refresh behavior. Re-run after deployment is required. + +Risks: + +- `/api/current-match` still has direct RCON sample behavior by design and remains separate architectural debt. +- `/api/servers` freshness now depends on an external runner/cache refresh path being active. +- Production latency must be confirmed after deploy because local tests validate behavior, not live infrastructure timing. + +## Recovery Note + +During recovery, an accidental root-level `tests/` package left by the interrupted session was folded into the existing backend test module and removed from the task scope. The repository convention for this coverage is `backend/tests`. + +## Change Budget + +The task intentionally exceeds five files only because it includes required tests, documentation and the done-task record. Product/frontend assets and configuration remain untouched. diff --git a/backend/app/payloads.py b/backend/app/payloads.py index b018a07..8335890 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -118,48 +118,33 @@ def build_discord_payload() -> dict[str, object]: def build_servers_payload() -> dict[str, object]: - """Return current server status, refreshing stale snapshots before responding.""" + """Return current server status from persisted snapshots only.""" max_snapshot_age_seconds = get_refresh_interval_seconds() persisted_items = _select_primary_snapshot_items( _enrich_server_items(list_latest_snapshots()) ) persisted_snapshot_at = _resolve_last_snapshot_at(persisted_items) persisted_snapshot_age_seconds = _calculate_snapshot_age_seconds(persisted_snapshot_at) - - refresh_attempted = _should_refresh_snapshot( - persisted_items, - persisted_snapshot_age_seconds, - max_snapshot_age_seconds, - ) refresh_errors: list[dict[str, object]] = [] refresh_source_policy = build_source_policy( primary_source=get_live_data_source_kind(), - selected_source="none", + selected_source="persisted-snapshot" if persisted_items else "none", fallback_reason=None, - source_attempts=[], + source_attempts=[ + build_source_attempt( + source="persisted-snapshot", + role="served-response", + status="success" if persisted_items else "empty", + reason="public-servers-read-is-cache-only", + ) + ], ) - if refresh_attempted: - refreshed_items, refresh_errors, refresh_source_policy = _try_collect_real_time_snapshot() - if refreshed_items: - refreshed_snapshot_at = _resolve_last_snapshot_at(refreshed_items) - refreshed_snapshot_age_seconds = _calculate_snapshot_age_seconds(refreshed_snapshot_at) - return _build_servers_response( - items=refreshed_items, - response_source=_build_live_response_source(refresh_source_policy), - last_snapshot_at=refreshed_snapshot_at, - snapshot_age_seconds=refreshed_snapshot_age_seconds, - max_snapshot_age_seconds=max_snapshot_age_seconds, - refresh_attempted=True, - refresh_status="success", - refresh_errors=refresh_errors, - source_policy=refresh_source_policy, - ) - if persisted_items: - refresh_status = "failed" if refresh_attempted else "not-needed" response_source = ( - "persisted-stale-snapshot" if refresh_attempted else "persisted-fresh-snapshot" + "persisted-stale-snapshot" + if _is_snapshot_stale(persisted_snapshot_age_seconds, max_snapshot_age_seconds) + else "persisted-fresh-snapshot" ) return _build_servers_response( items=persisted_items, @@ -167,12 +152,12 @@ def build_servers_payload() -> dict[str, object]: last_snapshot_at=persisted_snapshot_at, snapshot_age_seconds=persisted_snapshot_age_seconds, max_snapshot_age_seconds=max_snapshot_age_seconds, - refresh_attempted=refresh_attempted, - refresh_status=refresh_status, + refresh_attempted=False, + refresh_status="cache-only", refresh_errors=refresh_errors, source_policy=_infer_live_source_policy_from_items( persisted_items, - refresh_attempted=refresh_attempted, + refresh_attempted=False, refresh_errors=refresh_errors, ), ) @@ -189,8 +174,8 @@ def build_servers_payload() -> dict[str, object]: "max_snapshot_age_seconds": max_snapshot_age_seconds, "is_stale": True, "freshness": "stale", - "refresh_attempted": refresh_attempted, - "refresh_status": "failed" if refresh_attempted else "not-needed", + "refresh_attempted": False, + "refresh_status": "cache-only", "refresh_errors": refresh_errors, **refresh_source_policy, "items": [], @@ -415,16 +400,27 @@ def build_current_match_kill_feed_payload( origin = get_trusted_public_scoreboard_origin(server_slug) if origin is None: raise ValueError("Unsupported current match server.") - feed = list_current_match_kill_feed( - server_key=origin.slug, - limit=limit, - since_event_id=since_event_id, - ) + try: + feed = list_current_match_kill_feed( + server_key=origin.slug, + limit=limit, + since_event_id=since_event_id, + ensure_storage=False, + ) + source_policy = _build_current_match_admin_log_source_policy(status="success") + except Exception as error: # noqa: BLE001 - public live read must degrade cleanly + feed = _empty_current_match_kill_feed_payload() + source_policy = _build_current_match_admin_log_source_policy( + status="error", + error_reason=_public_error_reason(error), + message=str(error), + ) return { "status": "ok", "data": { "server_slug": origin.slug, "server_name": origin.display_name, + **source_policy, **feed, }, } @@ -435,17 +431,86 @@ def build_current_match_player_stats_payload(*, server_slug: str) -> dict[str, o origin = get_trusted_public_scoreboard_origin(server_slug) if origin is None: raise ValueError("Unsupported current match server.") - stats = list_current_match_player_stats(server_key=origin.slug) + try: + stats = list_current_match_player_stats( + server_key=origin.slug, + ensure_storage=False, + ) + source_policy = _build_current_match_admin_log_source_policy(status="success") + except Exception as error: # noqa: BLE001 - public live read must degrade cleanly + stats = _empty_current_match_player_stats_payload() + source_policy = _build_current_match_admin_log_source_policy( + status="error", + error_reason=_public_error_reason(error), + message=str(error), + ) return { "status": "ok", "data": { "server_slug": origin.slug, "server_name": origin.display_name, + **source_policy, **stats, }, } +def _empty_current_match_kill_feed_payload() -> dict[str, object]: + return { + "scope": "no-current-match-events", + "confidence": "unavailable", + "stale_events_filtered": 0, + "items": [], + } + + +def _empty_current_match_player_stats_payload() -> dict[str, object]: + return { + "scope": "no-current-match-events", + "confidence": "unavailable", + "source": "rcon-admin-log-current-match-summary", + "updated_at": None, + "stale_events_filtered": 0, + "items": [], + } + + +def _build_current_match_admin_log_source_policy( + *, + status: str, + error_reason: str | None = None, + message: str | None = None, +) -> dict[str, object]: + return build_source_policy( + primary_source=SOURCE_KIND_RCON, + selected_source="rcon-admin-log", + fallback_used=status != "success", + fallback_reason=error_reason, + source_attempts=[ + build_source_attempt( + source="rcon-admin-log", + role="read-model", + status=status, + reason=error_reason, + message=message, + ) + ], + ) + + +def _public_error_reason(error: Exception) -> str: + if isinstance(error, FileNotFoundError): + return "admin-log-read-model-unavailable" + if isinstance(error, TimeoutError): + return "admin-log-read-timeout" + message = str(error).lower() + if "timeout" in message or "timed out" in message: + return "admin-log-read-timeout" + if "does not exist" in message or "no such table" in message: + return "admin-log-read-model-unavailable" + return "admin-log-read-failed" + + def _query_current_match_rcon_sample(server_slug: str) -> dict[str, object] | None: """Read one configured trusted RCON target for the current-match view.""" try: @@ -2314,6 +2379,13 @@ def _should_refresh_snapshot( return snapshot_age_seconds > max_snapshot_age_seconds +def _is_snapshot_stale( + snapshot_age_seconds: int | None, + max_snapshot_age_seconds: int, +) -> bool: + return snapshot_age_seconds is None or snapshot_age_seconds > max_snapshot_age_seconds + + def _try_collect_real_time_snapshot() -> tuple[ list[dict[str, object]], list[dict[str, object]], diff --git a/backend/app/rcon_admin_log_storage.py b/backend/app/rcon_admin_log_storage.py index c479839..21e022a 100644 --- a/backend/app/rcon_admin_log_storage.py +++ b/backend/app/rcon_admin_log_storage.py @@ -348,16 +348,21 @@ def list_current_match_kill_feed( since_event_id: str | None = None, db_path: Path | None = None, now: datetime | None = None, + ensure_storage: bool = True, ) -> dict[str, object]: """Return safe recent kill rows for one AdminLog server window.""" - resolved_path = initialize_rcon_admin_log_storage(db_path=db_path) + resolved_path = ( + initialize_rcon_admin_log_storage(db_path=db_path) + if ensure_storage + else db_path or get_storage_path() + ) since_row_id = _parse_current_match_event_row_id(since_event_id) if use_postgres_rcon_storage(explicit_sqlite_path=db_path): from .postgres_rcon_storage import connect_postgres_compat connection_scope = connect_postgres_compat() else: - connection_scope = closing(sqlite3.connect(resolved_path)) + connection_scope = closing(_connect_admin_log_sqlite_read(resolved_path)) with connection_scope as connection: if isinstance(connection, sqlite3.Connection): @@ -469,15 +474,20 @@ def list_current_match_player_stats( server_key: str, db_path: Path | None = None, now: datetime | None = None, + ensure_storage: bool = True, ) -> dict[str, object]: """Return current-match participants and partial stats from the safe AdminLog window.""" - resolved_path = initialize_rcon_admin_log_storage(db_path=db_path) + resolved_path = ( + initialize_rcon_admin_log_storage(db_path=db_path) + if ensure_storage + else db_path or get_storage_path() + ) if use_postgres_rcon_storage(explicit_sqlite_path=db_path): from .postgres_rcon_storage import connect_postgres_compat connection_scope = connect_postgres_compat() else: - connection_scope = closing(sqlite3.connect(resolved_path)) + connection_scope = closing(_connect_admin_log_sqlite_read(resolved_path)) with connection_scope as connection: if isinstance(connection, sqlite3.Connection): @@ -592,6 +602,14 @@ def list_current_match_player_stats( } +def _connect_admin_log_sqlite_read(db_path: Path) -> sqlite3.Connection: + """Open AdminLog storage for read without creating files during public GETs.""" + if not db_path.exists(): + raise FileNotFoundError(f"AdminLog storage is not available: {db_path}") + uri = f"file:{db_path.as_posix()}?mode=ro" + return sqlite3.connect(uri, uri=True) + + def _resolve_current_match_window( connection: sqlite3.Connection | object, *, diff --git a/backend/tests/test_current_match_payload.py b/backend/tests/test_current_match_payload.py index 51b844d..1656071 100644 --- a/backend/tests/test_current_match_payload.py +++ b/backend/tests/test_current_match_payload.py @@ -1,7 +1,9 @@ from http import HTTPStatus from datetime import datetime, timezone +import unittest from unittest.mock import patch +from app import payloads from app.payloads import build_current_match_payload from app.rcon_admin_log_storage import list_current_match_player_stats, persist_rcon_admin_log_entries from app.rcon_client import RconServerTarget @@ -494,6 +496,72 @@ def test_current_match_player_stats_filter_stale_recent_events(tmp_path): assert stats["items"] == [] +class CurrentMatchPublicEndpointHardeningTests(unittest.TestCase): + def test_kill_feed_degrades_when_admin_log_read_fails(self) -> None: + with patch.object( + payloads, + "list_current_match_kill_feed", + side_effect=TimeoutError("read timed out"), + ): + result = payloads.build_current_match_kill_feed_payload( + server_slug="comunidad-hispana-01", + limit=30, + ) + + data = result["data"] + self.assertEqual(result["status"], "ok") + self.assertEqual(data["items"], []) + self.assertEqual(data["confidence"], "unavailable") + self.assertTrue(data["fallback_used"]) + self.assertEqual(data["fallback_reason"], "admin-log-read-timeout") + + def test_player_stats_degrades_when_admin_log_read_fails(self) -> None: + with patch.object( + payloads, + "list_current_match_player_stats", + side_effect=RuntimeError("no such table: rcon_admin_log_events"), + ): + result = payloads.build_current_match_player_stats_payload( + server_slug="comunidad-hispana-01", + ) + + data = result["data"] + self.assertEqual(result["status"], "ok") + self.assertEqual(data["items"], []) + self.assertEqual(data["updated_at"], None) + self.assertTrue(data["fallback_used"]) + self.assertEqual(data["fallback_reason"], "admin-log-read-model-unavailable") + + def test_servers_payload_does_not_refresh_live_on_public_get(self) -> None: + stale_snapshot = { + "server_name": "Comunidad Hispana #01", + "external_server_id": "comunidad-hispana-01", + "captured_at": "2020-01-01T00:00:00Z", + "snapshot_origin": "real-rcon", + "current_map": "carentan", + } + fake_live_source = type( + "FakeLiveSource", + (), + {"build_target_index": lambda self: {}}, + )() + + with ( + patch.object(payloads, "list_latest_snapshots", return_value=[stale_snapshot]), + patch.object(payloads, "get_live_data_source", return_value=fake_live_source), + patch.object(payloads, "_try_collect_real_time_snapshot") as refresh, + ): + result = payloads.build_servers_payload() + + refresh.assert_not_called() + data = result["data"] + self.assertEqual(result["status"], "ok") + self.assertEqual(data["items"][0]["external_server_id"], "comunidad-hispana-01") + self.assertEqual(data["refresh_attempted"], False) + self.assertEqual(data["refresh_status"], "cache-only") + self.assertEqual(data["source"], "persisted-stale-snapshot") + + def _build_with_rcon_sample(sample: dict[str, object]) -> dict[str, object]: with ( patch("app.payloads.load_rcon_targets", return_value=(TARGET,)), diff --git a/docs/FULL_APPLICATION_REQUEST_AUDIT.md b/docs/FULL_APPLICATION_REQUEST_AUDIT.md index 54a0273..14f2ee9 100644 --- a/docs/FULL_APPLICATION_REQUEST_AUDIT.md +++ b/docs/FULL_APPLICATION_REQUEST_AUDIT.md @@ -83,6 +83,31 @@ Estado de severidad esperado tras deploy: Siguiente fix prioritario actualizado: Fase 2 de `TASK-225` en task separada o continuacion autorizada, centrada en hardening de `/api/current-match/kills` y `/api/current-match/players` sin cambiar configuracion RCON. La validacion final debe repetirse en el entorno donde RCON este local. +## Estado post-fix TASK-226 + +Fecha: 2026-06-10 +Alcance aplicado: hardening de endpoints publicos RCON/live pendientes, sin cambiar hosts, puertos, `27001`, variables de entorno ni configuracion de servidores. + +Cambios de codigo aplicados: + +- `/api/current-match/kills`: el payload usa AdminLog en modo lectura publica sin inicializar storage (`ensure_storage=False`). Si falta el read model, hay timeout o falla la lectura, devuelve `status: ok`, `items: []`, `confidence: unavailable`, `fallback_used: true` y `fallback_reason` controlado (`admin-log-read-model-unavailable`, `admin-log-read-timeout` o `admin-log-read-failed`). +- `/api/current-match/players`: aplica el mismo patron de degradacion controlada sobre player stats de AdminLog. Una excepcion de storage ya no se propaga como 500 sin cuerpo. +- `/api/servers`: el builder publico deja de llamar `_try_collect_real_time_snapshot()` y no refresca RCON/A2S en cada GET publico. Sirve snapshots persistidos, marca `freshness`/`is_stale` y expone `refresh_status: "cache-only"`. + +Estado de severidad esperado tras deploy: + +| Endpoint | Severidad TASK-224 | Estado TASK-226 en codigo | Severidad esperada tras deploy | +| --- | --- | --- | --- | +| `/api/current-match/kills` | CRITICAL | Read-only AdminLog, degradacion JSON controlada | OK o WARNING si falta read model | +| `/api/current-match/players` | CRITICAL | Read-only AdminLog, degradacion JSON controlada | OK o WARNING si falta read model | +| `/api/servers` | WARNING | Snapshot/cache-only; sin refresh RCON live en request publica | OK o WARNING si snapshot stale | + +Notas: + +- `/api/current-match` conserva deuda separada porque todavia puede intentar muestra RCON directa antes de caer a snapshot. +- La frescura de `/api/servers` depende ahora del proceso/runner que mantenga snapshots calientes fuera del request publico. +- Auditoria parcial contra produccion ejecutada el 2026-06-10 antes de desplegar este codigo: `current-match` todavia mostro los timeouts/500 previos y `/api/servers` midio 4249.71 ms. Repetir tras deploy para confirmar los tiempos post-fix reales. + ## Evidencia ejecutada Comandos ejecutados: diff --git a/docs/PERFORMANCE_PUBLIC_QUERY_AUDIT.md b/docs/PERFORMANCE_PUBLIC_QUERY_AUDIT.md index 71e4c69..77372ef 100644 --- a/docs/PERFORMANCE_PUBLIC_QUERY_AUDIT.md +++ b/docs/PERFORMANCE_PUBLIC_QUERY_AUDIT.md @@ -6,12 +6,14 @@ Esta auditoria revisa las rutas publicas que alimentan `ranking`, `stats`, `hist 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. + 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. +- `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-226, kills/players 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. 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. @@ -29,7 +31,7 @@ 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. +- `current-match` expone una excepcion relevante: `/api/current-match` consulta RCON directo en la ruta publica cuando encuentra target valido. Kills/players ya leen AdminLog sin inicializar storage en request publico. ## Inventario de Endpoints Publicos @@ -43,9 +45,9 @@ Flujo observado: | `/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 | +| `/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 | 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 | 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/cache persistido | Sin refresh live en GET publico | 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: