Restore near-real-time public server status
This commit is contained in:
@@ -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.
|
||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
|
from .a2s_client import DEFAULT_A2S_TIMEOUT
|
||||||
from .collector import collect_server_snapshots
|
from .collector import collect_server_snapshots
|
||||||
from .config import get_historical_data_source_kind, get_live_data_source_kind
|
from .config import get_historical_data_source_kind, get_live_data_source_kind
|
||||||
from .providers.public_scoreboard_provider import PublicScoreboardHistoricalDataSource
|
from .providers.public_scoreboard_provider import PublicScoreboardHistoricalDataSource
|
||||||
@@ -48,7 +49,12 @@ class LiveDataSource(Protocol):
|
|||||||
|
|
||||||
source_kind: str
|
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."""
|
"""Collect one live snapshot batch."""
|
||||||
|
|
||||||
def build_target_index(self) -> dict[str | None, object]:
|
def build_target_index(self) -> dict[str | None, object]:
|
||||||
@@ -61,11 +67,17 @@ class A2SLiveDataSource:
|
|||||||
|
|
||||||
source_kind: str = LIVE_SOURCE_A2S
|
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(
|
return collect_server_snapshots(
|
||||||
source_mode="a2s",
|
source_mode="a2s",
|
||||||
allow_controlled_fallback=False,
|
allow_controlled_fallback=False,
|
||||||
persist=persist,
|
persist=persist,
|
||||||
|
timeout=timeout_seconds if timeout_seconds is not None else DEFAULT_A2S_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
def build_target_index(self) -> dict[str | None, A2SServerTarget]:
|
def build_target_index(self) -> dict[str | None, A2SServerTarget]:
|
||||||
@@ -84,12 +96,20 @@ class RconFirstLiveDataSource:
|
|||||||
fallback_source: A2SLiveDataSource = A2SLiveDataSource()
|
fallback_source: A2SLiveDataSource = A2SLiveDataSource()
|
||||||
source_kind: str = SOURCE_KIND_RCON
|
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]] = []
|
attempts: list[dict[str, object]] = []
|
||||||
fallback_reason: str | None = None
|
fallback_reason: str | None = None
|
||||||
|
|
||||||
try:
|
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
|
except Exception as error: # noqa: BLE001 - source arbitration keeps fallback controlled
|
||||||
attempts.append(
|
attempts.append(
|
||||||
build_source_attempt(
|
build_source_attempt(
|
||||||
@@ -133,7 +153,10 @@ class RconFirstLiveDataSource:
|
|||||||
fallback_reason = "rcon-live-returned-no-usable-snapshots"
|
fallback_reason = "rcon-live-returned-no-usable-snapshots"
|
||||||
|
|
||||||
try:
|
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
|
except Exception as error: # noqa: BLE001 - keep combined failure explicit
|
||||||
attempts.append(
|
attempts.append(
|
||||||
build_source_attempt(
|
build_source_attempt(
|
||||||
|
|||||||
@@ -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 .scoreboard_origins import get_trusted_public_scoreboard_origin
|
||||||
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history
|
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]:
|
def build_health_payload() -> dict[str, str]:
|
||||||
"""Return a small status payload without committing to business contracts."""
|
"""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]:
|
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()
|
max_snapshot_age_seconds = get_refresh_interval_seconds()
|
||||||
persisted_items = _select_primary_snapshot_items(
|
persisted_items = _select_primary_snapshot_items(
|
||||||
_enrich_server_items(list_latest_snapshots())
|
_enrich_server_items(list_latest_snapshots())
|
||||||
)
|
)
|
||||||
persisted_snapshot_at = _resolve_last_snapshot_at(persisted_items)
|
persisted_snapshot_at = _resolve_last_snapshot_at(persisted_items)
|
||||||
persisted_snapshot_age_seconds = _calculate_snapshot_age_seconds(persisted_snapshot_at)
|
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_errors: list[dict[str, object]] = []
|
||||||
refresh_source_policy = build_source_policy(
|
refresh_source_policy = build_source_policy(
|
||||||
primary_source=get_live_data_source_kind(),
|
primary_source=get_live_data_source_kind(),
|
||||||
selected_source="persisted-snapshot" if persisted_items else "none",
|
selected_source="none",
|
||||||
fallback_reason=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:
|
if persisted_items:
|
||||||
|
refresh_status = "failed" if refresh_attempted else "not-needed"
|
||||||
response_source = (
|
response_source = (
|
||||||
"persisted-stale-snapshot"
|
"persisted-stale-snapshot"
|
||||||
if _is_snapshot_stale(persisted_snapshot_age_seconds, max_snapshot_age_seconds)
|
if refresh_attempted
|
||||||
else "persisted-fresh-snapshot"
|
else "persisted-fresh-snapshot"
|
||||||
)
|
)
|
||||||
return _build_servers_response(
|
return _build_servers_response(
|
||||||
@@ -152,12 +171,12 @@ def build_servers_payload() -> dict[str, object]:
|
|||||||
last_snapshot_at=persisted_snapshot_at,
|
last_snapshot_at=persisted_snapshot_at,
|
||||||
snapshot_age_seconds=persisted_snapshot_age_seconds,
|
snapshot_age_seconds=persisted_snapshot_age_seconds,
|
||||||
max_snapshot_age_seconds=max_snapshot_age_seconds,
|
max_snapshot_age_seconds=max_snapshot_age_seconds,
|
||||||
refresh_attempted=False,
|
refresh_attempted=refresh_attempted,
|
||||||
refresh_status="cache-only",
|
refresh_status=refresh_status,
|
||||||
refresh_errors=refresh_errors,
|
refresh_errors=refresh_errors,
|
||||||
source_policy=_infer_live_source_policy_from_items(
|
source_policy=_infer_live_source_policy_from_items(
|
||||||
persisted_items,
|
persisted_items,
|
||||||
refresh_attempted=False,
|
refresh_attempted=refresh_attempted,
|
||||||
refresh_errors=refresh_errors,
|
refresh_errors=refresh_errors,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -174,8 +193,8 @@ def build_servers_payload() -> dict[str, object]:
|
|||||||
"max_snapshot_age_seconds": max_snapshot_age_seconds,
|
"max_snapshot_age_seconds": max_snapshot_age_seconds,
|
||||||
"is_stale": True,
|
"is_stale": True,
|
||||||
"freshness": "stale",
|
"freshness": "stale",
|
||||||
"refresh_attempted": False,
|
"refresh_attempted": refresh_attempted,
|
||||||
"refresh_status": "cache-only",
|
"refresh_status": "failed" if refresh_attempted else "not-needed",
|
||||||
"refresh_errors": refresh_errors,
|
"refresh_errors": refresh_errors,
|
||||||
**refresh_source_policy,
|
**refresh_source_policy,
|
||||||
"items": [],
|
"items": [],
|
||||||
@@ -2391,7 +2410,39 @@ def _try_collect_real_time_snapshot() -> tuple[
|
|||||||
list[dict[str, object]],
|
list[dict[str, object]],
|
||||||
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")
|
snapshots = payload.get("snapshots")
|
||||||
items = _select_primary_snapshot_items(_enrich_server_items(list(snapshots or [])))
|
items = _select_primary_snapshot_items(_enrich_server_items(list(snapshots or [])))
|
||||||
errors = payload.get("errors")
|
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(
|
def _build_servers_response(
|
||||||
*,
|
*,
|
||||||
items: list[dict[str, object]],
|
items: list[dict[str, object]],
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ class RconLiveDataSource:
|
|||||||
|
|
||||||
source_kind: str = "rcon"
|
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()
|
configured_targets = load_rcon_targets()
|
||||||
if not configured_targets:
|
if not configured_targets:
|
||||||
raise RuntimeError("No RCON targets configured in HLL_BACKEND_RCON_TARGETS.")
|
raise RuntimeError("No RCON targets configured in HLL_BACKEND_RCON_TARGETS.")
|
||||||
@@ -30,7 +35,12 @@ class RconLiveDataSource:
|
|||||||
|
|
||||||
for target in configured_targets:
|
for target in configured_targets:
|
||||||
try:
|
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
|
except Exception as error: # noqa: BLE001 - keep provider failures controlled
|
||||||
errors.append(
|
errors.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -533,7 +533,71 @@ class CurrentMatchPublicEndpointHardeningTests(unittest.TestCase):
|
|||||||
self.assertTrue(data["fallback_used"])
|
self.assertTrue(data["fallback_used"])
|
||||||
self.assertEqual(data["fallback_reason"], "admin-log-read-model-unavailable")
|
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 = {
|
stale_snapshot = {
|
||||||
"server_name": "Comunidad Hispana #01",
|
"server_name": "Comunidad Hispana #01",
|
||||||
"external_server_id": "comunidad-hispana-01",
|
"external_server_id": "comunidad-hispana-01",
|
||||||
@@ -541,26 +605,21 @@ class CurrentMatchPublicEndpointHardeningTests(unittest.TestCase):
|
|||||||
"snapshot_origin": "real-rcon",
|
"snapshot_origin": "real-rcon",
|
||||||
"current_map": "carentan",
|
"current_map": "carentan",
|
||||||
}
|
}
|
||||||
fake_live_source = type(
|
fake_live_source = _FakeLiveSource(collect_error=RuntimeError("live source down"))
|
||||||
"FakeLiveSource",
|
|
||||||
(),
|
|
||||||
{"build_target_index": lambda self: {}},
|
|
||||||
)()
|
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(payloads, "list_latest_snapshots", return_value=[stale_snapshot]),
|
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, "get_live_data_source", return_value=fake_live_source),
|
||||||
patch.object(payloads, "_try_collect_real_time_snapshot") as refresh,
|
|
||||||
):
|
):
|
||||||
result = payloads.build_servers_payload()
|
result = payloads.build_servers_payload()
|
||||||
|
|
||||||
refresh.assert_not_called()
|
|
||||||
data = result["data"]
|
data = result["data"]
|
||||||
self.assertEqual(result["status"], "ok")
|
self.assertEqual(result["status"], "ok")
|
||||||
self.assertEqual(data["items"][0]["external_server_id"], "comunidad-hispana-01")
|
self.assertEqual(data["items"][0]["external_server_id"], "comunidad-hispana-01")
|
||||||
self.assertEqual(data["refresh_attempted"], False)
|
self.assertEqual(data["refresh_attempted"], True)
|
||||||
self.assertEqual(data["refresh_status"], "cache-only")
|
self.assertEqual(data["refresh_status"], "failed")
|
||||||
self.assertEqual(data["source"], "persisted-stale-snapshot")
|
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:
|
def test_kill_feed_postgres_read_only_does_not_initialize_storage(self) -> None:
|
||||||
connection = _FakeAdminLogConnection(
|
connection = _FakeAdminLogConnection(
|
||||||
@@ -661,3 +720,37 @@ class _FakeAdminLogConnection:
|
|||||||
cursor.fetchone.return_value = result
|
cursor.fetchone.return_value = result
|
||||||
cursor.fetchall.return_value = result
|
cursor.fetchall.return_value = result
|
||||||
return cursor
|
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
|
||||||
|
|||||||
@@ -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
|
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
|
## Evidencia ejecutada
|
||||||
|
|
||||||
Comandos ejecutados:
|
Comandos ejecutados:
|
||||||
|
|||||||
@@ -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-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:
|
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 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.
|
- `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.
|
- 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.
|
- 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
|
## 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.
|
- `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.
|
- `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
|
## 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` | `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/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/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 |
|
| `/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:
|
Notas:
|
||||||
|
|||||||
Reference in New Issue
Block a user