Harden current match and servers public endpoints

This commit is contained in:
devRaGonSa
2026-06-10 19:48:12 +02:00
parent ee76bfde1c
commit ac1816f688
6 changed files with 369 additions and 48 deletions

View File

@@ -0,0 +1,136 @@
---
id: TASK-226
title: Harden current-match and servers public endpoints
status: done
type: backend
team: Backend Senior
supporting_teams:
- Analista
- Arquitecto Python
roadmap_item: foundation
priority: high
---
# TASK-226 - Harden current-match and servers public endpoints
## Goal
Harden the public endpoints still affected by RCON/live dependencies after `TASK-225`:
- `/api/current-match/kills`
- `/api/current-match/players`
- `/api/servers`
The objective is not to redesign RCON connectivity. Public HTTP reads must not hang indefinitely or return uncontrolled 500 responses when live RCON/AdminLog data is slow, unavailable or missing.
## Context
`TASK-225` stabilized:
- `/api/stats/players/search`
- `/api/stats/players/{player_id}`
- `/api/historical/matches/detail`
Production validation after `TASK-225` showed those endpoints responding OK without fallback. Remaining audit debt was RCON/live:
- `/api/current-match/kills` timed out or returned 500.
- `/api/current-match/players` timed out for `comunidad-hispana-01`.
- `/api/servers` could spend about 4.3s refreshing RCON from a public GET.
RCON timeout in the current test environment must not be interpreted automatically as wrong configuration. Backend/RCON `127.0.0.1`, hosts, ports and `27001` may be correct in final production and must not be changed in this task.
## Steps
1. Inspect public route and payload chains.
2. Make current-match AdminLog reads public read-only and controlled.
3. Make `/api/servers` return configuration/cache/snapshot data without live refresh in the request.
4. Add focused tests for controlled degradation and no public live refresh.
5. Validate and document the outcome.
## Files to Read First
- `ai/architecture-index.md`
- `ai/repo-context.md`
- `ai/orchestrator/backend-senior.md`
- `backend/app/routes.py`
- `backend/app/payloads.py`
- `backend/app/rcon_admin_log_storage.py`
- `backend/app/rcon_client.py`
- `backend/app/providers/rcon_provider.py`
- `docs/FULL_APPLICATION_REQUEST_AUDIT.md`
- `docs/PERFORMANCE_PUBLIC_QUERY_AUDIT.md`
## Expected Files to Modify
- `backend/app/payloads.py`
- `backend/app/rcon_admin_log_storage.py`
- `backend/tests/test_current_match_payload.py`
- `docs/FULL_APPLICATION_REQUEST_AUDIT.md`
- `docs/PERFORMANCE_PUBLIC_QUERY_AUDIT.md`
- `ai/tasks/done/TASK-226-harden-current-match-and-servers-public-endpoints.md`
## Constraints
- Do not run `ai-platform run`.
- Do not commit or push.
- Do not touch RCON hosts, ports, credentials, environment variables, Docker networking or server connection configuration.
- Do not change `27001`.
- Do not replace backend/RCON `127.0.0.1`; it may be correct in production.
- Do not touch frontend assets, weapon assets, clan assets, SVGs or physical images.
- Do not modify `frontend/assets/img/weapons/` or `frontend/assets/img/clans/`.
- Do not modify `ai/system-metrics.md`.
- Do not reactivate Elo/MMR.
- Do not reintroduce Comunidad Hispana #03.
## Validation
Required:
- `python -m compileall backend/app`
- `python -m unittest tests.test_current_match_payload`
- current-match related tests if present
- payload related tests if present
- partial production audit if possible:
- `python scripts/audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --filter current-match --output tmp/task226_current_match_audit.json`
- `python scripts/audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --filter servers --output tmp/task226_servers_audit.json`
## Outcome
Implemented:
- `/api/current-match/kills` now calls AdminLog storage in read-only mode with `ensure_storage=False`. If the AdminLog read model is missing, slow or raises, the public payload returns `status: ok`, `items: []`, `confidence: unavailable`, and source metadata with `fallback_used: true` and an `admin-log-*` reason.
- `/api/current-match/players` uses the same read-only/degrade pattern and returns an empty controlled current-match player stats payload instead of propagating storage exceptions as uncontrolled 500s.
- `rcon_admin_log_storage` gained read-only SQLite opening for current-match public reads so missing local storage does not create files or initialize schemas during GET handling.
- `/api/servers` no longer calls `_try_collect_real_time_snapshot()` from the public payload builder. It serves persisted snapshots only, marks stale/fresh state, and returns `refresh_status: "cache-only"`.
- Focused unit tests were added under `backend/tests/test_current_match_payload.py` for kill feed degradation, player stats degradation, and ensuring `/api/servers` does not trigger live refresh in a public GET.
Not changed:
- RCON hosts, ports, credentials, environment variables and server configuration.
- `27001`.
- Frontend JS, because existing `partida-actual.js` catches endpoint errors and clears loading states for kills/players.
- Assets, SVGs and physical images.
- Elo/MMR and Comunidad Hispana #03.
Validation performed:
- `python -m compileall backend/app`: OK.
- `cd backend; python -m unittest tests.test_current_match_payload`: OK, 3 tests from `CurrentMatchPublicEndpointHardeningTests`.
- Related current-match tests: no previous test modules were present in this repo checkout.
- Related payload tests: no previous payload-specific test modules were present in this repo checkout.
- `python scripts/audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --filter current-match --output tmp/task226_current_match_audit.json`: executed against current production deployment. It still shows the pre-deploy failures: kills CH01 timeout 30054.96 ms, players CH01 timeout 30024.09 ms, kills CH02 timeout 30068.39 ms, players CH02 500 in 7497.46 ms. Re-run after deployment is required.
- `python scripts/audit_public_requests.py --base-url https://comunidadhll.devzamode.es --timeout 30 --filter servers --output tmp/task226_servers_audit.json`: executed against current production deployment. `/api/servers` still measured 200 in 4249.71 ms, consistent with pre-deploy live refresh behavior. Re-run after deployment is required.
Risks:
- `/api/current-match` still has direct RCON sample behavior by design and remains separate architectural debt.
- `/api/servers` freshness now depends on an external runner/cache refresh path being active.
- Production latency must be confirmed after deploy because local tests validate behavior, not live infrastructure timing.
## Recovery Note
During recovery, an accidental root-level `tests/` package left by the interrupted session was folded into the existing backend test module and removed from the task scope. The repository convention for this coverage is `backend/tests`.
## Change Budget
The task intentionally exceeds five files only because it includes required tests, documentation and the done-task record. Product/frontend assets and configuration remain untouched.

View File

@@ -118,48 +118,33 @@ 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, refreshing stale snapshots before responding.""" """Return current server status from persisted snapshots only."""
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="none", selected_source="persisted-snapshot" if persisted_items else "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" if refresh_attempted else "persisted-fresh-snapshot" "persisted-stale-snapshot"
if _is_snapshot_stale(persisted_snapshot_age_seconds, max_snapshot_age_seconds)
else "persisted-fresh-snapshot"
) )
return _build_servers_response( return _build_servers_response(
items=persisted_items, items=persisted_items,
@@ -167,12 +152,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=refresh_attempted, refresh_attempted=False,
refresh_status=refresh_status, refresh_status="cache-only",
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=refresh_attempted, refresh_attempted=False,
refresh_errors=refresh_errors, refresh_errors=refresh_errors,
), ),
) )
@@ -189,8 +174,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": refresh_attempted, "refresh_attempted": False,
"refresh_status": "failed" if refresh_attempted else "not-needed", "refresh_status": "cache-only",
"refresh_errors": refresh_errors, "refresh_errors": refresh_errors,
**refresh_source_policy, **refresh_source_policy,
"items": [], "items": [],
@@ -415,16 +400,27 @@ def build_current_match_kill_feed_payload(
origin = get_trusted_public_scoreboard_origin(server_slug) origin = get_trusted_public_scoreboard_origin(server_slug)
if origin is None: if origin is None:
raise ValueError("Unsupported current match server.") raise ValueError("Unsupported current match server.")
feed = list_current_match_kill_feed( try:
server_key=origin.slug, feed = list_current_match_kill_feed(
limit=limit, server_key=origin.slug,
since_event_id=since_event_id, limit=limit,
) since_event_id=since_event_id,
ensure_storage=False,
)
source_policy = _build_current_match_admin_log_source_policy(status="success")
except Exception as error: # noqa: BLE001 - public live read must degrade cleanly
feed = _empty_current_match_kill_feed_payload()
source_policy = _build_current_match_admin_log_source_policy(
status="error",
error_reason=_public_error_reason(error),
message=str(error),
)
return { return {
"status": "ok", "status": "ok",
"data": { "data": {
"server_slug": origin.slug, "server_slug": origin.slug,
"server_name": origin.display_name, "server_name": origin.display_name,
**source_policy,
**feed, **feed,
}, },
} }
@@ -435,17 +431,86 @@ def build_current_match_player_stats_payload(*, server_slug: str) -> dict[str, o
origin = get_trusted_public_scoreboard_origin(server_slug) origin = get_trusted_public_scoreboard_origin(server_slug)
if origin is None: if origin is None:
raise ValueError("Unsupported current match server.") raise ValueError("Unsupported current match server.")
stats = list_current_match_player_stats(server_key=origin.slug) try:
stats = list_current_match_player_stats(
server_key=origin.slug,
ensure_storage=False,
)
source_policy = _build_current_match_admin_log_source_policy(status="success")
except Exception as error: # noqa: BLE001 - public live read must degrade cleanly
stats = _empty_current_match_player_stats_payload()
source_policy = _build_current_match_admin_log_source_policy(
status="error",
error_reason=_public_error_reason(error),
message=str(error),
)
return { return {
"status": "ok", "status": "ok",
"data": { "data": {
"server_slug": origin.slug, "server_slug": origin.slug,
"server_name": origin.display_name, "server_name": origin.display_name,
**source_policy,
**stats, **stats,
}, },
} }
def _empty_current_match_kill_feed_payload() -> dict[str, object]:
return {
"scope": "no-current-match-events",
"confidence": "unavailable",
"stale_events_filtered": 0,
"items": [],
}
def _empty_current_match_player_stats_payload() -> dict[str, object]:
return {
"scope": "no-current-match-events",
"confidence": "unavailable",
"source": "rcon-admin-log-current-match-summary",
"updated_at": None,
"stale_events_filtered": 0,
"items": [],
}
def _build_current_match_admin_log_source_policy(
*,
status: str,
error_reason: str | None = None,
message: str | None = None,
) -> dict[str, object]:
return build_source_policy(
primary_source=SOURCE_KIND_RCON,
selected_source="rcon-admin-log",
fallback_used=status != "success",
fallback_reason=error_reason,
source_attempts=[
build_source_attempt(
source="rcon-admin-log",
role="read-model",
status=status,
reason=error_reason,
message=message,
)
],
)
def _public_error_reason(error: Exception) -> str:
if isinstance(error, FileNotFoundError):
return "admin-log-read-model-unavailable"
if isinstance(error, TimeoutError):
return "admin-log-read-timeout"
message = str(error).lower()
if "timeout" in message or "timed out" in message:
return "admin-log-read-timeout"
if "does not exist" in message or "no such table" in message:
return "admin-log-read-model-unavailable"
return "admin-log-read-failed"
def _query_current_match_rcon_sample(server_slug: str) -> dict[str, object] | None: def _query_current_match_rcon_sample(server_slug: str) -> dict[str, object] | None:
"""Read one configured trusted RCON target for the current-match view.""" """Read one configured trusted RCON target for the current-match view."""
try: try:
@@ -2314,6 +2379,13 @@ def _should_refresh_snapshot(
return snapshot_age_seconds > max_snapshot_age_seconds return snapshot_age_seconds > max_snapshot_age_seconds
def _is_snapshot_stale(
snapshot_age_seconds: int | None,
max_snapshot_age_seconds: int,
) -> bool:
return snapshot_age_seconds is None or snapshot_age_seconds > max_snapshot_age_seconds
def _try_collect_real_time_snapshot() -> tuple[ def _try_collect_real_time_snapshot() -> tuple[
list[dict[str, object]], list[dict[str, object]],
list[dict[str, object]], list[dict[str, object]],

View File

@@ -348,16 +348,21 @@ def list_current_match_kill_feed(
since_event_id: str | None = None, since_event_id: str | None = None,
db_path: Path | None = None, db_path: Path | None = None,
now: datetime | None = None, now: datetime | None = None,
ensure_storage: bool = True,
) -> dict[str, object]: ) -> dict[str, object]:
"""Return safe recent kill rows for one AdminLog server window.""" """Return safe recent kill rows for one AdminLog server window."""
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path) resolved_path = (
initialize_rcon_admin_log_storage(db_path=db_path)
if ensure_storage
else db_path or get_storage_path()
)
since_row_id = _parse_current_match_event_row_id(since_event_id) since_row_id = _parse_current_match_event_row_id(since_event_id)
if use_postgres_rcon_storage(explicit_sqlite_path=db_path): if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import connect_postgres_compat from .postgres_rcon_storage import connect_postgres_compat
connection_scope = connect_postgres_compat() connection_scope = connect_postgres_compat()
else: else:
connection_scope = closing(sqlite3.connect(resolved_path)) connection_scope = closing(_connect_admin_log_sqlite_read(resolved_path))
with connection_scope as connection: with connection_scope as connection:
if isinstance(connection, sqlite3.Connection): if isinstance(connection, sqlite3.Connection):
@@ -469,15 +474,20 @@ def list_current_match_player_stats(
server_key: str, server_key: str,
db_path: Path | None = None, db_path: Path | None = None,
now: datetime | None = None, now: datetime | None = None,
ensure_storage: bool = True,
) -> dict[str, object]: ) -> dict[str, object]:
"""Return current-match participants and partial stats from the safe AdminLog window.""" """Return current-match participants and partial stats from the safe AdminLog window."""
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path) resolved_path = (
initialize_rcon_admin_log_storage(db_path=db_path)
if ensure_storage
else db_path or get_storage_path()
)
if use_postgres_rcon_storage(explicit_sqlite_path=db_path): if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import connect_postgres_compat from .postgres_rcon_storage import connect_postgres_compat
connection_scope = connect_postgres_compat() connection_scope = connect_postgres_compat()
else: else:
connection_scope = closing(sqlite3.connect(resolved_path)) connection_scope = closing(_connect_admin_log_sqlite_read(resolved_path))
with connection_scope as connection: with connection_scope as connection:
if isinstance(connection, sqlite3.Connection): if isinstance(connection, sqlite3.Connection):
@@ -592,6 +602,14 @@ def list_current_match_player_stats(
} }
def _connect_admin_log_sqlite_read(db_path: Path) -> sqlite3.Connection:
"""Open AdminLog storage for read without creating files during public GETs."""
if not db_path.exists():
raise FileNotFoundError(f"AdminLog storage is not available: {db_path}")
uri = f"file:{db_path.as_posix()}?mode=ro"
return sqlite3.connect(uri, uri=True)
def _resolve_current_match_window( def _resolve_current_match_window(
connection: sqlite3.Connection | object, connection: sqlite3.Connection | object,
*, *,

View File

@@ -1,7 +1,9 @@
from http import HTTPStatus from http import HTTPStatus
from datetime import datetime, timezone from datetime import datetime, timezone
import unittest
from unittest.mock import patch from unittest.mock import patch
from app import payloads
from app.payloads import build_current_match_payload from app.payloads import build_current_match_payload
from app.rcon_admin_log_storage import list_current_match_player_stats, persist_rcon_admin_log_entries from app.rcon_admin_log_storage import list_current_match_player_stats, persist_rcon_admin_log_entries
from app.rcon_client import RconServerTarget from app.rcon_client import RconServerTarget
@@ -494,6 +496,72 @@ def test_current_match_player_stats_filter_stale_recent_events(tmp_path):
assert stats["items"] == [] assert stats["items"] == []
class CurrentMatchPublicEndpointHardeningTests(unittest.TestCase):
def test_kill_feed_degrades_when_admin_log_read_fails(self) -> None:
with patch.object(
payloads,
"list_current_match_kill_feed",
side_effect=TimeoutError("read timed out"),
):
result = payloads.build_current_match_kill_feed_payload(
server_slug="comunidad-hispana-01",
limit=30,
)
data = result["data"]
self.assertEqual(result["status"], "ok")
self.assertEqual(data["items"], [])
self.assertEqual(data["confidence"], "unavailable")
self.assertTrue(data["fallback_used"])
self.assertEqual(data["fallback_reason"], "admin-log-read-timeout")
def test_player_stats_degrades_when_admin_log_read_fails(self) -> None:
with patch.object(
payloads,
"list_current_match_player_stats",
side_effect=RuntimeError("no such table: rcon_admin_log_events"),
):
result = payloads.build_current_match_player_stats_payload(
server_slug="comunidad-hispana-01",
)
data = result["data"]
self.assertEqual(result["status"], "ok")
self.assertEqual(data["items"], [])
self.assertEqual(data["updated_at"], None)
self.assertTrue(data["fallback_used"])
self.assertEqual(data["fallback_reason"], "admin-log-read-model-unavailable")
def test_servers_payload_does_not_refresh_live_on_public_get(self) -> None:
stale_snapshot = {
"server_name": "Comunidad Hispana #01",
"external_server_id": "comunidad-hispana-01",
"captured_at": "2020-01-01T00:00:00Z",
"snapshot_origin": "real-rcon",
"current_map": "carentan",
}
fake_live_source = type(
"FakeLiveSource",
(),
{"build_target_index": lambda self: {}},
)()
with (
patch.object(payloads, "list_latest_snapshots", return_value=[stale_snapshot]),
patch.object(payloads, "get_live_data_source", return_value=fake_live_source),
patch.object(payloads, "_try_collect_real_time_snapshot") as refresh,
):
result = payloads.build_servers_payload()
refresh.assert_not_called()
data = result["data"]
self.assertEqual(result["status"], "ok")
self.assertEqual(data["items"][0]["external_server_id"], "comunidad-hispana-01")
self.assertEqual(data["refresh_attempted"], False)
self.assertEqual(data["refresh_status"], "cache-only")
self.assertEqual(data["source"], "persisted-stale-snapshot")
def _build_with_rcon_sample(sample: dict[str, object]) -> dict[str, object]: def _build_with_rcon_sample(sample: dict[str, object]) -> dict[str, object]:
with ( with (
patch("app.payloads.load_rcon_targets", return_value=(TARGET,)), patch("app.payloads.load_rcon_targets", return_value=(TARGET,)),

View File

@@ -83,6 +83,31 @@ Estado de severidad esperado tras deploy:
Siguiente fix prioritario actualizado: Fase 2 de `TASK-225` en task separada o continuacion autorizada, centrada en hardening de `/api/current-match/kills` y `/api/current-match/players` sin cambiar configuracion RCON. La validacion final debe repetirse en el entorno donde RCON este local. Siguiente fix prioritario actualizado: Fase 2 de `TASK-225` en task separada o continuacion autorizada, centrada en hardening de `/api/current-match/kills` y `/api/current-match/players` sin cambiar configuracion RCON. La validacion final debe repetirse en el entorno donde RCON este local.
## Estado post-fix TASK-226
Fecha: 2026-06-10
Alcance aplicado: hardening de endpoints publicos RCON/live pendientes, sin cambiar hosts, puertos, `27001`, variables de entorno ni configuracion de servidores.
Cambios de codigo aplicados:
- `/api/current-match/kills`: el payload usa AdminLog en modo lectura publica sin inicializar storage (`ensure_storage=False`). Si falta el read model, hay timeout o falla la lectura, devuelve `status: ok`, `items: []`, `confidence: unavailable`, `fallback_used: true` y `fallback_reason` controlado (`admin-log-read-model-unavailable`, `admin-log-read-timeout` o `admin-log-read-failed`).
- `/api/current-match/players`: aplica el mismo patron de degradacion controlada sobre player stats de AdminLog. Una excepcion de storage ya no se propaga como 500 sin cuerpo.
- `/api/servers`: el builder publico deja de llamar `_try_collect_real_time_snapshot()` y no refresca RCON/A2S en cada GET publico. Sirve snapshots persistidos, marca `freshness`/`is_stale` y expone `refresh_status: "cache-only"`.
Estado de severidad esperado tras deploy:
| Endpoint | Severidad TASK-224 | Estado TASK-226 en codigo | Severidad esperada tras deploy |
| --- | --- | --- | --- |
| `/api/current-match/kills` | CRITICAL | Read-only AdminLog, degradacion JSON controlada | OK o WARNING si falta read model |
| `/api/current-match/players` | CRITICAL | Read-only AdminLog, degradacion JSON controlada | OK o WARNING si falta read model |
| `/api/servers` | WARNING | Snapshot/cache-only; sin refresh RCON live en request publica | OK o WARNING si snapshot stale |
Notas:
- `/api/current-match` conserva deuda separada porque todavia puede intentar muestra RCON directa antes de caer a snapshot.
- La frescura de `/api/servers` depende ahora del proceso/runner que mantenga snapshots calientes fuera del request publico.
- Auditoria parcial contra produccion ejecutada el 2026-06-10 antes de desplegar este codigo: `current-match` todavia mostro los timeouts/500 previos y `/api/servers` midio 4249.71 ms. Repetir tras deploy para confirmar los tiempos post-fix reales.
## Evidencia ejecutada ## Evidencia ejecutada
Comandos ejecutados: Comandos ejecutados:

View File

@@ -6,12 +6,14 @@ Esta auditoria revisa las rutas publicas que alimentan `ranking`, `stats`, `hist
Actualizacion TASK-225, 2026-06-10: la deuda P1 de `stats search` y `stats player profile` fue corregida en codigo para que los GET publicos usen `player_search_index` y `player_period_stats` en modo read-only estricto, sin inicializar storage ni caer a runtime fallback pesado. La medicion HTTP final requiere redeploy. `current-match` sigue pendiente porque depende de RCON live y debe tratarse como hardening/degradacion sin cambiar hosts, puertos ni configuracion RCON. Actualizacion TASK-225, 2026-06-10: la deuda P1 de `stats search` y `stats player profile` fue corregida en codigo para que los GET publicos usen `player_search_index` y `player_period_stats` en modo read-only estricto, sin inicializar storage ni caer a runtime fallback pesado. La medicion HTTP final requiere redeploy. `current-match` sigue pendiente porque depende de RCON live y debe tratarse como hardening/degradacion sin cambiar hosts, puertos ni configuracion RCON.
Actualizacion TASK-226, 2026-06-10: `/api/current-match/kills` y `/api/current-match/players` pasan a usar AdminLog en lectura publica sin inicializar storage y con degradacion JSON controlada si el read model no esta disponible o falla. `/api/servers` pasa a ser snapshot/cache-only en el GET publico y ya no dispara refresh RCON/A2S live durante la lectura. La deuda restante de `current-match` queda en `/api/current-match`, que todavia puede intentar una muestra RCON directa antes de caer a snapshot.
Conclusiones principales: 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.
- El cuello de botella visible actual mas claro esta en frontend: `frontend/assets/js/ranking.js` y `frontend/assets/js/stats.js` bloquean la carga principal detras de `/health`, y `ranking.js` no tiene proteccion contra request race ni limpieza robusta del estado de carga. - El cuello de botella visible actual mas claro esta en frontend: `frontend/assets/js/ranking.js` y `frontend/assets/js/stats.js` bloquean la carga principal detras de `/health`, y `ranking.js` no tiene proteccion contra request race ni limpieza robusta del estado de carga.
- `historico.js` es hoy la referencia mas sana del frontend publico: usa snapshots, cache TTL, deduplicacion de peticiones y `requestId` para ignorar respuestas obsoletas. - `historico.js` es hoy la referencia mas sana del frontend publico: usa snapshots, cache TTL, deduplicacion de peticiones y `requestId` para ignorar respuestas obsoletas.
- `partida-actual.js` no bloquea por `/health`, pero hace polling agresivo y paralelo a tres endpoints (`/api/current-match`, `/api/current-match/kills`, `/api/current-match/players`) sin `AbortController`, con intervalos de 1.5 s y 3 s que pueden amplificar carga y re-render innecesario. - `partida-actual.js` no bloquea por `/health`, pero hace polling agresivo y paralelo a tres endpoints (`/api/current-match`, `/api/current-match/kills`, `/api/current-match/players`) sin `AbortController`, con intervalos de 1.5 s y 3 s que pueden amplificar carga y re-render innecesario. Tras TASK-226, kills/players degradan desde backend en JSON controlado.
- En backend siguen existiendo fallbacks runtime publicos sobre tablas materializadas grandes para `ranking`, `stats search` y `stats player profile`. Son mejores que consultar RCON directo, pero siguen rompiendo la meta de servir lecturas publicas desde read models dedicados. - 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. Eso contradice la regla objetivo de esta auditoria y debe tratarse como deuda arquitectonica explicita aunque hoy sea un requisito funcional de la pagina live.
@@ -29,7 +31,7 @@ Flujo observado:
- `ranking` y `stats` mezclan frontend secuencial con backend que aun puede caer a runtime sobre tablas materializadas si falta snapshot o read model. - `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: consulta RCON directo en la ruta publica cuando encuentra target valido. - `current-match` expone una excepcion relevante: `/api/current-match` consulta RCON directo en la ruta publica cuando encuentra target valido. Kills/players ya leen AdminLog sin inicializar storage en request publico.
## Inventario de Endpoints Publicos ## Inventario de Endpoints Publicos
@@ -43,9 +45,9 @@ Flujo observado:
| `/api/historical/snapshots/recent-matches` | `frontend/assets/js/historico.js` | `build_recent_historical_matches_snapshot_payload()` | Snapshot publico de recent matches | Segun source kind puede usar RCON read model o fallback publico | Si | P2 | | `/api/historical/snapshots/recent-matches` | `frontend/assets/js/historico.js` | `build_recent_historical_matches_snapshot_payload()` | Snapshot publico de recent matches | Segun source kind puede usar RCON read model o fallback publico | Si | P2 |
| `/api/historical/matches/detail` | `frontend/assets/js/historico-partida.js` | `build_historical_match_detail_payload()` | Read model detalle de partida | Intenta `get_rcon_historical_match_detail()`, luego fallback a storage historico publico | Si | P2 | | `/api/historical/matches/detail` | `frontend/assets/js/historico-partida.js` | `build_historical_match_detail_payload()` | Read model detalle de partida | Intenta `get_rcon_historical_match_detail()`, luego fallback a storage historico publico | Si | P2 |
| `/api/current-match` | `frontend/assets/js/partida-actual.js` | `build_current_match_payload()` | Read model live propio | Primero intenta `_query_current_match_rcon_sample()` directo; luego fallback a `/api/servers` snapshot | Si, y toca RCON directo | P0 | | `/api/current-match` | `frontend/assets/js/partida-actual.js` | `build_current_match_payload()` | Read model live propio | Primero intenta `_query_current_match_rcon_sample()` directo; luego fallback a `/api/servers` snapshot | Si, y toca RCON directo | P0 |
| `/api/current-match/kills` | `frontend/assets/js/partida-actual.js` | `build_current_match_kill_feed_payload()` | Read model live propio de kill feed | `list_current_match_kill_feed()` desde evidencia AdminLog materializada | No se observo fallback a scoreboard | P1 | | `/api/current-match/kills` | `frontend/assets/js/partida-actual.js` | `build_current_match_kill_feed_payload()` | Read model live propio de kill feed | AdminLog materializado en modo read-only publico | Degradacion JSON controlada si falla read model | P2 |
| `/api/current-match/players` | `frontend/assets/js/partida-actual.js` | `build_current_match_player_stats_payload()` | Read model live propio de player stats | `list_current_match_player_stats()` desde evidencia AdminLog materializada | No se observo fallback a scoreboard | P1 | | `/api/current-match/players` | `frontend/assets/js/partida-actual.js` | `build_current_match_player_stats_payload()` | Read model live propio de player stats | AdminLog materializado en modo read-only publico | Degradacion JSON controlada si falla read model | P2 |
| `/api/servers` | `frontend/assets/js/main.js`, fallback de current-match | `build_servers_payload()` | Snapshot live de servidores | Snapshot local/live payload | Sin fallback costoso visible | P2 | | `/api/servers` | `frontend/assets/js/main.js`, fallback de current-match | `build_servers_payload()` | Snapshot live de servidores | Snapshot/cache persistido | Sin refresh live en GET publico | P2 |
| `/health` | `frontend/assets/js/main.js`, `ranking.js`, `stats.js` | `build_health_payload()` | N/A | Check tecnico | No aplica | P1 por bloqueo UI, no por backend | | `/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: