Restore near-real-time public server status

This commit is contained in:
devRaGonSa
2026-06-10 20:12:00 +02:00
parent ba37000360
commit 68c6b5dac0
7 changed files with 434 additions and 37 deletions

View File

@@ -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.

View File

@@ -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(

View File

@@ -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]],

View File

@@ -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(
{ {

View File

@@ -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

View File

@@ -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:

View File

@@ -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: