diff --git a/ai/tasks/done/TASK-228-restore-near-real-time-public-server-status.md b/ai/tasks/done/TASK-228-restore-near-real-time-public-server-status.md new file mode 100644 index 0000000..db6327c --- /dev/null +++ b/ai/tasks/done/TASK-228-restore-near-real-time-public-server-status.md @@ -0,0 +1,162 @@ +--- +id: TASK-228 +title: Restore near-real-time public server status +status: done +type: backend +team: Backend Senior +supporting_teams: [] +roadmap_item: foundation +priority: high +--- + +# TASK-228 - Restore near-real-time public server status + +## Goal + +Restaurar el comportamiento correcto de la home "Estado actual de servidores" para que `/api/servers` vuelva a servir estado live o casi-live de los servidores cuando la fuente RCON/A2S este disponible: + +- mapa actual +- region/servidor +- jugadores +- estado disponible + +La ruta no debe depender exclusivamente de snapshots historicos ni devolver `items: []` cuando puede consultar live de forma controlada. + +## Context + +Tras `TASK-226`, `/api/servers` respondia rapido pero vacio cuando no existian snapshots: + +- `source: no-snapshot-available` +- `refresh_attempted: false` +- `refresh_status: cache-only` +- `items: []` + +Eso dejaba la home mostrando "Actualizado no disponible" e "Informacion de servidores disponible mas adelante". Ese resultado no es aceptable para una seccion cuyo contrato es estado actual/casi actual. + +## Files Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/backend-senior.md` +- `backend/README.md` +- `backend/app/routes.py` +- `backend/app/payloads.py` +- `backend/app/data_sources.py` +- `backend/app/providers/rcon_provider.py` +- `backend/app/collector.py` +- `backend/tests/test_current_match_payload.py` + +## Call Chain Analysis + +Cadena frontend/backend: + +1. La home usa `frontend/assets/js/main.js`. +2. Ese JS consulta `/api/servers` para renderizar las tarjetas de estado. +3. `backend/app/routes.py` mapea `/api/servers` a `build_servers_payload()`. +4. `build_servers_payload()` lee `list_latest_snapshots()` como ultimo estado conocido. +5. Antes de `TASK-226`, si no habia cache o estaba stale, llamaba `_try_collect_real_time_snapshot()`. +6. `_try_collect_real_time_snapshot()` usa `get_live_data_source().collect_snapshots(...)`. +7. Con `HLL_BACKEND_LIVE_DATA_SOURCE=rcon`, la fuente efectiva es RCON-first con fallback A2S controlado. + +## Root Cause + +`TASK-226` cambio `build_servers_payload()` a modo `cache-only` estricto: + +- dejo de llamar `_try_collect_real_time_snapshot()` desde `/api/servers`. +- marco `refresh_attempted: false` siempre. +- devolvia `items: []` cuando no habia snapshot persistido. + +Ese cambio arreglo latencia de request, pero rompio el contrato funcional de la home cuando no hay cache caliente. + +## Changes + +- `backend/app/payloads.py` + - Restaura la politica near-real-time: + - si hay snapshot fresco, sirve cache sin refresh. + - si no hay snapshot o esta stale, intenta refresh live. + - si live devuelve items, responde con `real-time-rcon-refresh`, `real-time-a2s-fallback` o equivalente. + - si live falla, devuelve snapshot stale si existe. + - si live falla y no hay snapshot, responde `items: []` con error controlado y metadata de fallback. + - `_try_collect_real_time_snapshot()` captura excepciones y evita 500. + - El refresh publico usa timeout interno corto `PUBLIC_SERVER_STATUS_TIMEOUT_SECONDS = 2.5` sin cambiar variables de entorno ni configuracion RCON. +- `backend/app/data_sources.py` + - Extiende el contrato live para aceptar `timeout_seconds` opcional. + - Propaga ese timeout a RCON-first y A2S cuando la llamada publica lo solicita. +- `backend/app/providers/rcon_provider.py` + - Propaga `timeout_seconds` opcional a `query_live_server_sample()`. + - Las llamadas que no pasan timeout siguen usando `HLL_BACKEND_RCON_TIMEOUT_SECONDS`. +- `backend/tests/test_current_match_payload.py` + - Ajusta el test de `/api/servers` de `cache-only` a near-real-time. + - Cubre refresh live cuando no hay cache. + - Cubre respuesta controlada si live falla sin cache. + - Cubre fallback a snapshot stale si live falla con cache existente. +- `docs/FULL_APPLICATION_REQUEST_AUDIT.md` + - Documenta el estado post-fix de `TASK-228`. +- `docs/PERFORMANCE_PUBLIC_QUERY_AUDIT.md` + - Actualiza `/api/servers` como near-real-time controlado en vez de cache-only. + +## Validation + +Validaciones ejecutadas: + +```powershell +python -m compileall backend/app +cd backend +python -m unittest tests.test_current_match_payload +python -m unittest tests.test_current_match_payload tests.test_rcon_admin_log_storage tests.test_historical_snapshot_refresh +``` + +Resultados: + +- `python -m compileall backend/app`: OK. +- `cd backend; python -m unittest tests.test_current_match_payload`: OK, 7 tests. +- `cd backend; python -m unittest tests.test_current_match_payload tests.test_rcon_admin_log_storage tests.test_historical_snapshot_refresh`: OK, 20 tests. + +Auditoria local: + +- No se ejecuto porque `http://127.0.0.1:8000/health` no respondio desde el host. + +Comandos de validacion de produccion tras redeploy: + +```powershell +$base = "https://comunidadhll.devzamode.es" + +Invoke-WebRequest "$base/api/servers" | + Select-Object -ExpandProperty Content + +Invoke-WebRequest "$base/api/servers/latest" | + Select-Object -ExpandProperty Content + +Invoke-WebRequest "$base/api/servers/history" | + Select-Object -ExpandProperty Content + +python .\scripts\audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --filter servers --output tmp\task228_servers_audit_after.json +``` + +## Outcome + +`/api/servers` vuelve a intentar estado live/casi-live de forma controlada cuando no hay cache o el cache esta vencido. Si RCON/A2S responde, la home recibe items reales. Si RCON/A2S no responde en el entorno de pruebas, el endpoint devuelve JSON controlado y no debe bloquear 30 s ni devolver 500 vacio. + +## Constraints Confirmed + +- No se cambio `27001`. +- No se cambiaron hosts ni puertos RCON. +- No se cambio `127.0.0.1`. +- No se cambiaron variables de entorno de servidores. +- No se cambio configuracion de servidores. +- No se tocaron player search/profile/historical detail. +- No se tocaron current-match/kills ni current-match/players salvo por tests compartidos. +- No se reactivo Elo/MMR. +- No se reintrodujo Comunidad Hispana #03. +- No se toco frontend. +- No se tocaron assets, SVGs ni imagenes fisicas. +- No se toco `frontend/assets/img/weapons/`. +- No se toco `frontend/assets/img/clans/`. +- No se toco `ai/system-metrics.md`. +- No se incluyo `tmp/`. + +## Risks + +- El refresh live sigue siendo sincronico y consulta targets configurados. El timeout publico corto reduce el riesgo de 30 s, pero con varios targets o un servidor que responde parcialmente puede haber latencia acumulada. +- No se persiste el snapshot live desde el GET publico para evitar DDL/inicializaciones pesadas en la lectura publica. `/api/servers/latest` y `/api/servers/history` pueden seguir vacios hasta que un proceso de captura/persistencia actualice almacenamiento. +- La validacion final de que la home muestra servidores requiere redeploy en el entorno donde RCON/live este disponible. diff --git a/backend/app/data_sources.py b/backend/app/data_sources.py index f8846b0..f02fc88 100644 --- a/backend/app/data_sources.py +++ b/backend/app/data_sources.py @@ -5,6 +5,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import Protocol +from .a2s_client import DEFAULT_A2S_TIMEOUT from .collector import collect_server_snapshots from .config import get_historical_data_source_kind, get_live_data_source_kind from .providers.public_scoreboard_provider import PublicScoreboardHistoricalDataSource @@ -48,7 +49,12 @@ class LiveDataSource(Protocol): source_kind: str - def collect_snapshots(self, *, persist: bool) -> dict[str, object]: + def collect_snapshots( + self, + *, + persist: bool, + timeout_seconds: float | None = None, + ) -> dict[str, object]: """Collect one live snapshot batch.""" def build_target_index(self) -> dict[str | None, object]: @@ -61,11 +67,17 @@ class A2SLiveDataSource: source_kind: str = LIVE_SOURCE_A2S - def collect_snapshots(self, *, persist: bool) -> dict[str, object]: + def collect_snapshots( + self, + *, + persist: bool, + timeout_seconds: float | None = None, + ) -> dict[str, object]: return collect_server_snapshots( source_mode="a2s", allow_controlled_fallback=False, persist=persist, + timeout=timeout_seconds if timeout_seconds is not None else DEFAULT_A2S_TIMEOUT, ) def build_target_index(self) -> dict[str | None, A2SServerTarget]: @@ -84,12 +96,20 @@ class RconFirstLiveDataSource: fallback_source: A2SLiveDataSource = A2SLiveDataSource() source_kind: str = SOURCE_KIND_RCON - def collect_snapshots(self, *, persist: bool) -> dict[str, object]: + def collect_snapshots( + self, + *, + persist: bool, + timeout_seconds: float | None = None, + ) -> dict[str, object]: attempts: list[dict[str, object]] = [] fallback_reason: str | None = None try: - primary_payload = self.primary_source.collect_snapshots(persist=persist) + primary_payload = self.primary_source.collect_snapshots( + persist=persist, + timeout_seconds=timeout_seconds, + ) except Exception as error: # noqa: BLE001 - source arbitration keeps fallback controlled attempts.append( build_source_attempt( @@ -133,7 +153,10 @@ class RconFirstLiveDataSource: fallback_reason = "rcon-live-returned-no-usable-snapshots" try: - fallback_payload = self.fallback_source.collect_snapshots(persist=persist) + fallback_payload = self.fallback_source.collect_snapshots( + persist=persist, + timeout_seconds=timeout_seconds, + ) except Exception as error: # noqa: BLE001 - keep combined failure explicit attempts.append( build_source_attempt( diff --git a/backend/app/payloads.py b/backend/app/payloads.py index 8335890..bd9feaa 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -63,6 +63,8 @@ from .rcon_admin_log_storage import list_current_match_kill_feed, list_current_m from .scoreboard_origins import get_trusted_public_scoreboard_origin from .storage import list_latest_snapshots, list_server_history, list_snapshot_history +PUBLIC_SERVER_STATUS_TIMEOUT_SECONDS = 2.5 + def build_health_payload() -> dict[str, str]: """Return a small status payload without committing to business contracts.""" @@ -118,32 +120,49 @@ def build_discord_payload() -> dict[str, object]: def build_servers_payload() -> dict[str, object]: - """Return current server status from persisted snapshots only.""" + """Return current server status, refreshing stale snapshots before responding.""" 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="persisted-snapshot" if persisted_items else "none", + selected_source="none", fallback_reason=None, - 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", - ) - ], + source_attempts=[], ) + 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 _is_snapshot_stale(persisted_snapshot_age_seconds, max_snapshot_age_seconds) + if refresh_attempted else "persisted-fresh-snapshot" ) return _build_servers_response( @@ -152,12 +171,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=False, - refresh_status="cache-only", + refresh_attempted=refresh_attempted, + refresh_status=refresh_status, refresh_errors=refresh_errors, source_policy=_infer_live_source_policy_from_items( persisted_items, - refresh_attempted=False, + refresh_attempted=refresh_attempted, refresh_errors=refresh_errors, ), ) @@ -174,8 +193,8 @@ def build_servers_payload() -> dict[str, object]: "max_snapshot_age_seconds": max_snapshot_age_seconds, "is_stale": True, "freshness": "stale", - "refresh_attempted": False, - "refresh_status": "cache-only", + "refresh_attempted": refresh_attempted, + "refresh_status": "failed" if refresh_attempted else "not-needed", "refresh_errors": refresh_errors, **refresh_source_policy, "items": [], @@ -2391,7 +2410,39 @@ def _try_collect_real_time_snapshot() -> tuple[ list[dict[str, object]], dict[str, object], ]: - payload = get_live_data_source().collect_snapshots(persist=False) + try: + payload = get_live_data_source().collect_snapshots( + persist=False, + timeout_seconds=PUBLIC_SERVER_STATUS_TIMEOUT_SECONDS, + ) + except Exception as error: # noqa: BLE001 - public server status must degrade cleanly + reason = _public_server_refresh_error_reason(error) + return ( + [], + [ + { + "source": get_live_data_source_kind(), + "reason": reason, + "error_type": type(error).__name__, + "message": str(error), + } + ], + build_source_policy( + primary_source=get_live_data_source_kind(), + selected_source="none", + fallback_used=True, + fallback_reason=reason, + source_attempts=[ + build_source_attempt( + source=get_live_data_source_kind(), + role="primary", + status="error", + reason=reason, + message=str(error), + ) + ], + ), + ) snapshots = payload.get("snapshots") items = _select_primary_snapshot_items(_enrich_server_items(list(snapshots or []))) errors = payload.get("errors") @@ -2408,6 +2459,15 @@ def _try_collect_real_time_snapshot() -> tuple[ ) +def _public_server_refresh_error_reason(error: Exception) -> str: + message = str(error).lower() + if isinstance(error, TimeoutError) or "timeout" in message or "timed out" in message: + return "live-refresh-timeout" + if "no rcon targets" in message or "no live" in message or "configured" in message: + return "live-refresh-unavailable" + return "live-refresh-failed" + + def _build_servers_response( *, items: list[dict[str, object]], diff --git a/backend/app/providers/rcon_provider.py b/backend/app/providers/rcon_provider.py index 61e7eef..e8ef763 100644 --- a/backend/app/providers/rcon_provider.py +++ b/backend/app/providers/rcon_provider.py @@ -19,7 +19,12 @@ class RconLiveDataSource: source_kind: str = "rcon" - def collect_snapshots(self, *, persist: bool) -> dict[str, object]: + def collect_snapshots( + self, + *, + persist: bool, + timeout_seconds: float | None = None, + ) -> dict[str, object]: configured_targets = load_rcon_targets() if not configured_targets: raise RuntimeError("No RCON targets configured in HLL_BACKEND_RCON_TARGETS.") @@ -30,7 +35,12 @@ class RconLiveDataSource: for target in configured_targets: try: - normalized_records.append(query_live_server_sample(target)["normalized"]) + normalized_records.append( + query_live_server_sample( + target, + timeout_seconds=timeout_seconds, + )["normalized"] + ) except Exception as error: # noqa: BLE001 - keep provider failures controlled errors.append( { diff --git a/backend/tests/test_current_match_payload.py b/backend/tests/test_current_match_payload.py index d19c13d..a0ac1b0 100644 --- a/backend/tests/test_current_match_payload.py +++ b/backend/tests/test_current_match_payload.py @@ -533,7 +533,71 @@ class CurrentMatchPublicEndpointHardeningTests(unittest.TestCase): 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: + def test_servers_payload_refreshes_live_when_no_snapshot_exists(self) -> None: + live_snapshot = { + "server_name": "Comunidad Hispana #01", + "external_server_id": "comunidad-hispana-01", + "captured_at": "2026-06-10T10:00:00Z", + "snapshot_origin": "real-rcon", + "current_map": "carentan", + "players": 74, + "max_players": 100, + } + fake_live_source = _FakeLiveSource( + collect_payload={ + "snapshots": [live_snapshot], + "errors": [], + "primary_source": "rcon", + "selected_source": "rcon", + "fallback_used": False, + "fallback_reason": None, + "source_attempts": [ + { + "source": "rcon", + "role": "primary", + "status": "success", + "reason": None, + "message": None, + } + ], + } + ) + + with ( + patch.object(payloads, "list_latest_snapshots", return_value=[]), + patch.object(payloads, "get_live_data_source", return_value=fake_live_source), + ): + result = payloads.build_servers_payload() + + data = result["data"] + self.assertEqual(result["status"], "ok") + self.assertEqual(data["source"], "real-time-rcon-refresh") + self.assertEqual(data["refresh_attempted"], True) + self.assertEqual(data["refresh_status"], "success") + self.assertEqual(data["items"][0]["external_server_id"], "comunidad-hispana-01") + self.assertEqual(data["items"][0]["players"], 74) + self.assertEqual(fake_live_source.collect_calls, [(False, 2.5)]) + + def test_servers_payload_returns_controlled_empty_response_when_live_fails_without_cache(self) -> None: + fake_live_source = _FakeLiveSource(collect_error=TimeoutError("RCON timed out")) + + with ( + patch.object(payloads, "list_latest_snapshots", return_value=[]), + patch.object(payloads, "get_live_data_source", return_value=fake_live_source), + ): + result = payloads.build_servers_payload() + + data = result["data"] + self.assertEqual(result["status"], "ok") + self.assertEqual(data["items"], []) + self.assertEqual(data["source"], "no-snapshot-available") + self.assertEqual(data["refresh_attempted"], True) + self.assertEqual(data["refresh_status"], "failed") + self.assertEqual(data["fallback_used"], True) + self.assertEqual(data["fallback_reason"], "live-refresh-timeout") + self.assertEqual(data["refresh_errors"][0]["reason"], "live-refresh-timeout") + + def test_servers_payload_falls_back_to_stale_snapshot_when_live_refresh_fails(self) -> None: stale_snapshot = { "server_name": "Comunidad Hispana #01", "external_server_id": "comunidad-hispana-01", @@ -541,26 +605,21 @@ class CurrentMatchPublicEndpointHardeningTests(unittest.TestCase): "snapshot_origin": "real-rcon", "current_map": "carentan", } - fake_live_source = type( - "FakeLiveSource", - (), - {"build_target_index": lambda self: {}}, - )() + fake_live_source = _FakeLiveSource(collect_error=RuntimeError("live source down")) 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["refresh_attempted"], True) + self.assertEqual(data["refresh_status"], "failed") self.assertEqual(data["source"], "persisted-stale-snapshot") + self.assertEqual(data["refresh_errors"][0]["reason"], "live-refresh-failed") def test_kill_feed_postgres_read_only_does_not_initialize_storage(self) -> None: connection = _FakeAdminLogConnection( @@ -661,3 +720,37 @@ class _FakeAdminLogConnection: cursor.fetchone.return_value = result cursor.fetchall.return_value = result return cursor + + +class _FakeLiveSource: + def __init__( + self, + *, + collect_payload: dict[str, object] | None = None, + collect_error: Exception | None = None, + ) -> None: + self.collect_payload = collect_payload or { + "snapshots": [], + "errors": [], + "primary_source": "rcon", + "selected_source": "none", + "fallback_used": False, + "fallback_reason": None, + "source_attempts": [], + } + self.collect_error = collect_error + self.collect_calls: list[tuple[bool, float | None]] = [] + + def build_target_index(self) -> dict[str, object]: + return {} + + def collect_snapshots( + self, + *, + persist: bool, + timeout_seconds: float | None = None, + ) -> dict[str, object]: + self.collect_calls.append((persist, timeout_seconds)) + if self.collect_error is not None: + raise self.collect_error + return self.collect_payload diff --git a/docs/FULL_APPLICATION_REQUEST_AUDIT.md b/docs/FULL_APPLICATION_REQUEST_AUDIT.md index 4a08607..0bae11c 100644 --- a/docs/FULL_APPLICATION_REQUEST_AUDIT.md +++ b/docs/FULL_APPLICATION_REQUEST_AUDIT.md @@ -144,6 +144,53 @@ Comando de validacion de produccion tras redeploy: python .\scripts\audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --filter current-match --output tmp\task227_current_match_audit_after.json ``` +## Estado post-fix TASK-228 + +Fecha: 2026-06-10 +Alcance aplicado: restauracion de `/api/servers` como endpoint near-real-time controlado para la home. + +Causa confirmada: + +- `TASK-226` hizo que `/api/servers` dejara de llamar `_try_collect_real_time_snapshot()`. +- La ruta quedo en modo `cache-only`, con `refresh_attempted: false` y `refresh_status: cache-only`. +- Cuando no existian snapshots persistidos, respondia rapido pero con `source: no-snapshot-available` e `items: []`, aunque la fuente live pudiera estar disponible en produccion. + +Cambios de codigo aplicados: + +- `/api/servers` vuelve a usar la politica live/casi-live: + - sirve snapshot fresco si existe. + - intenta refresh live si no hay snapshot o el snapshot esta stale. + - devuelve live RCON/A2S si hay items. + - cae a snapshot stale si live falla y existe ultimo estado conocido. + - devuelve JSON controlado con error/fallback si live falla y no hay snapshot. +- El refresh publico usa timeout corto interno (`2.5s`) propagado a RCON/A2S sin cambiar variables de entorno, hosts, puertos ni configuracion de servidores. +- No se persiste desde el GET publico para evitar inicializaciones/DDL pesadas en lectura. + +Estado esperado tras redeploy: + +| Endpoint | Estado TASK-228 en codigo | Severidad esperada tras deploy | +| --- | --- | --- | +| `/api/servers` | Near-real-time controlado; live si cache falta/stale, stale fallback si live falla | OK o WARNING si live no disponible | +| `/api/servers/latest` | Sigue leyendo almacenamiento local | OK o WARNING si no hay snapshots persistidos | +| `/api/servers/history` | Sigue leyendo almacenamiento local | OK o WARNING si no hay snapshots persistidos | + +Comando de validacion de produccion tras redeploy: + +```powershell +$base = "https://comunidadhll.devzamode.es" + +Invoke-WebRequest "$base/api/servers" | + Select-Object -ExpandProperty Content + +Invoke-WebRequest "$base/api/servers/latest" | + Select-Object -ExpandProperty Content + +Invoke-WebRequest "$base/api/servers/history" | + Select-Object -ExpandProperty Content + +python .\scripts\audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --filter servers --output tmp\task228_servers_audit_after.json +``` + ## Evidencia ejecutada Comandos ejecutados: diff --git a/docs/PERFORMANCE_PUBLIC_QUERY_AUDIT.md b/docs/PERFORMANCE_PUBLIC_QUERY_AUDIT.md index 2eaf7e1..d3046c5 100644 --- a/docs/PERFORMANCE_PUBLIC_QUERY_AUDIT.md +++ b/docs/PERFORMANCE_PUBLIC_QUERY_AUDIT.md @@ -10,6 +10,8 @@ Actualizacion TASK-226, 2026-06-10: `/api/current-match/kills` y `/api/current-m 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. + 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. @@ -18,7 +20,7 @@ Conclusiones principales: - `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. 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. +- `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 @@ -33,7 +35,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: `/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`. +- `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 @@ -49,7 +51,7 @@ Flujo observado: | `/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/cache persistido | Sin refresh live en GET publico | 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: