Harden current match and servers public endpoints
This commit is contained in:
@@ -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.
|
||||
@@ -118,48 +118,33 @@ def build_discord_payload() -> dict[str, object]:
|
||||
|
||||
|
||||
def build_servers_payload() -> dict[str, object]:
|
||||
"""Return current server status, refreshing stale snapshots before responding."""
|
||||
"""Return current server status from persisted snapshots only."""
|
||||
max_snapshot_age_seconds = get_refresh_interval_seconds()
|
||||
persisted_items = _select_primary_snapshot_items(
|
||||
_enrich_server_items(list_latest_snapshots())
|
||||
)
|
||||
persisted_snapshot_at = _resolve_last_snapshot_at(persisted_items)
|
||||
persisted_snapshot_age_seconds = _calculate_snapshot_age_seconds(persisted_snapshot_at)
|
||||
|
||||
refresh_attempted = _should_refresh_snapshot(
|
||||
persisted_items,
|
||||
persisted_snapshot_age_seconds,
|
||||
max_snapshot_age_seconds,
|
||||
)
|
||||
refresh_errors: list[dict[str, object]] = []
|
||||
refresh_source_policy = build_source_policy(
|
||||
primary_source=get_live_data_source_kind(),
|
||||
selected_source="none",
|
||||
selected_source="persisted-snapshot" if persisted_items else "none",
|
||||
fallback_reason=None,
|
||||
source_attempts=[],
|
||||
source_attempts=[
|
||||
build_source_attempt(
|
||||
source="persisted-snapshot",
|
||||
role="served-response",
|
||||
status="success" if persisted_items else "empty",
|
||||
reason="public-servers-read-is-cache-only",
|
||||
)
|
||||
|
||||
if refresh_attempted:
|
||||
refreshed_items, refresh_errors, refresh_source_policy = _try_collect_real_time_snapshot()
|
||||
if refreshed_items:
|
||||
refreshed_snapshot_at = _resolve_last_snapshot_at(refreshed_items)
|
||||
refreshed_snapshot_age_seconds = _calculate_snapshot_age_seconds(refreshed_snapshot_at)
|
||||
return _build_servers_response(
|
||||
items=refreshed_items,
|
||||
response_source=_build_live_response_source(refresh_source_policy),
|
||||
last_snapshot_at=refreshed_snapshot_at,
|
||||
snapshot_age_seconds=refreshed_snapshot_age_seconds,
|
||||
max_snapshot_age_seconds=max_snapshot_age_seconds,
|
||||
refresh_attempted=True,
|
||||
refresh_status="success",
|
||||
refresh_errors=refresh_errors,
|
||||
source_policy=refresh_source_policy,
|
||||
],
|
||||
)
|
||||
|
||||
if persisted_items:
|
||||
refresh_status = "failed" if refresh_attempted else "not-needed"
|
||||
response_source = (
|
||||
"persisted-stale-snapshot" if refresh_attempted else "persisted-fresh-snapshot"
|
||||
"persisted-stale-snapshot"
|
||||
if _is_snapshot_stale(persisted_snapshot_age_seconds, max_snapshot_age_seconds)
|
||||
else "persisted-fresh-snapshot"
|
||||
)
|
||||
return _build_servers_response(
|
||||
items=persisted_items,
|
||||
@@ -167,12 +152,12 @@ def build_servers_payload() -> dict[str, object]:
|
||||
last_snapshot_at=persisted_snapshot_at,
|
||||
snapshot_age_seconds=persisted_snapshot_age_seconds,
|
||||
max_snapshot_age_seconds=max_snapshot_age_seconds,
|
||||
refresh_attempted=refresh_attempted,
|
||||
refresh_status=refresh_status,
|
||||
refresh_attempted=False,
|
||||
refresh_status="cache-only",
|
||||
refresh_errors=refresh_errors,
|
||||
source_policy=_infer_live_source_policy_from_items(
|
||||
persisted_items,
|
||||
refresh_attempted=refresh_attempted,
|
||||
refresh_attempted=False,
|
||||
refresh_errors=refresh_errors,
|
||||
),
|
||||
)
|
||||
@@ -189,8 +174,8 @@ def build_servers_payload() -> dict[str, object]:
|
||||
"max_snapshot_age_seconds": max_snapshot_age_seconds,
|
||||
"is_stale": True,
|
||||
"freshness": "stale",
|
||||
"refresh_attempted": refresh_attempted,
|
||||
"refresh_status": "failed" if refresh_attempted else "not-needed",
|
||||
"refresh_attempted": False,
|
||||
"refresh_status": "cache-only",
|
||||
"refresh_errors": refresh_errors,
|
||||
**refresh_source_policy,
|
||||
"items": [],
|
||||
@@ -415,16 +400,27 @@ def build_current_match_kill_feed_payload(
|
||||
origin = get_trusted_public_scoreboard_origin(server_slug)
|
||||
if origin is None:
|
||||
raise ValueError("Unsupported current match server.")
|
||||
try:
|
||||
feed = list_current_match_kill_feed(
|
||||
server_key=origin.slug,
|
||||
limit=limit,
|
||||
since_event_id=since_event_id,
|
||||
ensure_storage=False,
|
||||
)
|
||||
source_policy = _build_current_match_admin_log_source_policy(status="success")
|
||||
except Exception as error: # noqa: BLE001 - public live read must degrade cleanly
|
||||
feed = _empty_current_match_kill_feed_payload()
|
||||
source_policy = _build_current_match_admin_log_source_policy(
|
||||
status="error",
|
||||
error_reason=_public_error_reason(error),
|
||||
message=str(error),
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"server_slug": origin.slug,
|
||||
"server_name": origin.display_name,
|
||||
**source_policy,
|
||||
**feed,
|
||||
},
|
||||
}
|
||||
@@ -435,17 +431,86 @@ def build_current_match_player_stats_payload(*, server_slug: str) -> dict[str, o
|
||||
origin = get_trusted_public_scoreboard_origin(server_slug)
|
||||
if origin is None:
|
||||
raise ValueError("Unsupported current match server.")
|
||||
stats = list_current_match_player_stats(server_key=origin.slug)
|
||||
try:
|
||||
stats = list_current_match_player_stats(
|
||||
server_key=origin.slug,
|
||||
ensure_storage=False,
|
||||
)
|
||||
source_policy = _build_current_match_admin_log_source_policy(status="success")
|
||||
except Exception as error: # noqa: BLE001 - public live read must degrade cleanly
|
||||
stats = _empty_current_match_player_stats_payload()
|
||||
source_policy = _build_current_match_admin_log_source_policy(
|
||||
status="error",
|
||||
error_reason=_public_error_reason(error),
|
||||
message=str(error),
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"server_slug": origin.slug,
|
||||
"server_name": origin.display_name,
|
||||
**source_policy,
|
||||
**stats,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _empty_current_match_kill_feed_payload() -> dict[str, object]:
|
||||
return {
|
||||
"scope": "no-current-match-events",
|
||||
"confidence": "unavailable",
|
||||
"stale_events_filtered": 0,
|
||||
"items": [],
|
||||
}
|
||||
|
||||
|
||||
def _empty_current_match_player_stats_payload() -> dict[str, object]:
|
||||
return {
|
||||
"scope": "no-current-match-events",
|
||||
"confidence": "unavailable",
|
||||
"source": "rcon-admin-log-current-match-summary",
|
||||
"updated_at": None,
|
||||
"stale_events_filtered": 0,
|
||||
"items": [],
|
||||
}
|
||||
|
||||
|
||||
def _build_current_match_admin_log_source_policy(
|
||||
*,
|
||||
status: str,
|
||||
error_reason: str | None = None,
|
||||
message: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
return build_source_policy(
|
||||
primary_source=SOURCE_KIND_RCON,
|
||||
selected_source="rcon-admin-log",
|
||||
fallback_used=status != "success",
|
||||
fallback_reason=error_reason,
|
||||
source_attempts=[
|
||||
build_source_attempt(
|
||||
source="rcon-admin-log",
|
||||
role="read-model",
|
||||
status=status,
|
||||
reason=error_reason,
|
||||
message=message,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _public_error_reason(error: Exception) -> str:
|
||||
if isinstance(error, FileNotFoundError):
|
||||
return "admin-log-read-model-unavailable"
|
||||
if isinstance(error, TimeoutError):
|
||||
return "admin-log-read-timeout"
|
||||
message = str(error).lower()
|
||||
if "timeout" in message or "timed out" in message:
|
||||
return "admin-log-read-timeout"
|
||||
if "does not exist" in message or "no such table" in message:
|
||||
return "admin-log-read-model-unavailable"
|
||||
return "admin-log-read-failed"
|
||||
|
||||
|
||||
def _query_current_match_rcon_sample(server_slug: str) -> dict[str, object] | None:
|
||||
"""Read one configured trusted RCON target for the current-match view."""
|
||||
try:
|
||||
@@ -2314,6 +2379,13 @@ def _should_refresh_snapshot(
|
||||
return snapshot_age_seconds > max_snapshot_age_seconds
|
||||
|
||||
|
||||
def _is_snapshot_stale(
|
||||
snapshot_age_seconds: int | None,
|
||||
max_snapshot_age_seconds: int,
|
||||
) -> bool:
|
||||
return snapshot_age_seconds is None or snapshot_age_seconds > max_snapshot_age_seconds
|
||||
|
||||
|
||||
def _try_collect_real_time_snapshot() -> tuple[
|
||||
list[dict[str, object]],
|
||||
list[dict[str, object]],
|
||||
|
||||
@@ -348,16 +348,21 @@ def list_current_match_kill_feed(
|
||||
since_event_id: str | None = None,
|
||||
db_path: Path | None = None,
|
||||
now: datetime | None = None,
|
||||
ensure_storage: bool = True,
|
||||
) -> dict[str, object]:
|
||||
"""Return safe recent kill rows for one AdminLog server window."""
|
||||
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
|
||||
resolved_path = (
|
||||
initialize_rcon_admin_log_storage(db_path=db_path)
|
||||
if ensure_storage
|
||||
else db_path or get_storage_path()
|
||||
)
|
||||
since_row_id = _parse_current_match_event_row_id(since_event_id)
|
||||
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||
from .postgres_rcon_storage import connect_postgres_compat
|
||||
|
||||
connection_scope = connect_postgres_compat()
|
||||
else:
|
||||
connection_scope = closing(sqlite3.connect(resolved_path))
|
||||
connection_scope = closing(_connect_admin_log_sqlite_read(resolved_path))
|
||||
|
||||
with connection_scope as connection:
|
||||
if isinstance(connection, sqlite3.Connection):
|
||||
@@ -469,15 +474,20 @@ def list_current_match_player_stats(
|
||||
server_key: str,
|
||||
db_path: Path | None = None,
|
||||
now: datetime | None = None,
|
||||
ensure_storage: bool = True,
|
||||
) -> dict[str, object]:
|
||||
"""Return current-match participants and partial stats from the safe AdminLog window."""
|
||||
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
|
||||
resolved_path = (
|
||||
initialize_rcon_admin_log_storage(db_path=db_path)
|
||||
if ensure_storage
|
||||
else db_path or get_storage_path()
|
||||
)
|
||||
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||
from .postgres_rcon_storage import connect_postgres_compat
|
||||
|
||||
connection_scope = connect_postgres_compat()
|
||||
else:
|
||||
connection_scope = closing(sqlite3.connect(resolved_path))
|
||||
connection_scope = closing(_connect_admin_log_sqlite_read(resolved_path))
|
||||
|
||||
with connection_scope as connection:
|
||||
if isinstance(connection, sqlite3.Connection):
|
||||
@@ -592,6 +602,14 @@ def list_current_match_player_stats(
|
||||
}
|
||||
|
||||
|
||||
def _connect_admin_log_sqlite_read(db_path: Path) -> sqlite3.Connection:
|
||||
"""Open AdminLog storage for read without creating files during public GETs."""
|
||||
if not db_path.exists():
|
||||
raise FileNotFoundError(f"AdminLog storage is not available: {db_path}")
|
||||
uri = f"file:{db_path.as_posix()}?mode=ro"
|
||||
return sqlite3.connect(uri, uri=True)
|
||||
|
||||
|
||||
def _resolve_current_match_window(
|
||||
connection: sqlite3.Connection | object,
|
||||
*,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from http import HTTPStatus
|
||||
from datetime import datetime, timezone
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app import payloads
|
||||
from app.payloads import build_current_match_payload
|
||||
from app.rcon_admin_log_storage import list_current_match_player_stats, persist_rcon_admin_log_entries
|
||||
from app.rcon_client import RconServerTarget
|
||||
@@ -494,6 +496,72 @@ def test_current_match_player_stats_filter_stale_recent_events(tmp_path):
|
||||
assert stats["items"] == []
|
||||
|
||||
|
||||
class CurrentMatchPublicEndpointHardeningTests(unittest.TestCase):
|
||||
def test_kill_feed_degrades_when_admin_log_read_fails(self) -> None:
|
||||
with patch.object(
|
||||
payloads,
|
||||
"list_current_match_kill_feed",
|
||||
side_effect=TimeoutError("read timed out"),
|
||||
):
|
||||
result = payloads.build_current_match_kill_feed_payload(
|
||||
server_slug="comunidad-hispana-01",
|
||||
limit=30,
|
||||
)
|
||||
|
||||
data = result["data"]
|
||||
self.assertEqual(result["status"], "ok")
|
||||
self.assertEqual(data["items"], [])
|
||||
self.assertEqual(data["confidence"], "unavailable")
|
||||
self.assertTrue(data["fallback_used"])
|
||||
self.assertEqual(data["fallback_reason"], "admin-log-read-timeout")
|
||||
|
||||
def test_player_stats_degrades_when_admin_log_read_fails(self) -> None:
|
||||
with patch.object(
|
||||
payloads,
|
||||
"list_current_match_player_stats",
|
||||
side_effect=RuntimeError("no such table: rcon_admin_log_events"),
|
||||
):
|
||||
result = payloads.build_current_match_player_stats_payload(
|
||||
server_slug="comunidad-hispana-01",
|
||||
)
|
||||
|
||||
data = result["data"]
|
||||
self.assertEqual(result["status"], "ok")
|
||||
self.assertEqual(data["items"], [])
|
||||
self.assertEqual(data["updated_at"], None)
|
||||
self.assertTrue(data["fallback_used"])
|
||||
self.assertEqual(data["fallback_reason"], "admin-log-read-model-unavailable")
|
||||
|
||||
def test_servers_payload_does_not_refresh_live_on_public_get(self) -> None:
|
||||
stale_snapshot = {
|
||||
"server_name": "Comunidad Hispana #01",
|
||||
"external_server_id": "comunidad-hispana-01",
|
||||
"captured_at": "2020-01-01T00:00:00Z",
|
||||
"snapshot_origin": "real-rcon",
|
||||
"current_map": "carentan",
|
||||
}
|
||||
fake_live_source = type(
|
||||
"FakeLiveSource",
|
||||
(),
|
||||
{"build_target_index": lambda self: {}},
|
||||
)()
|
||||
|
||||
with (
|
||||
patch.object(payloads, "list_latest_snapshots", return_value=[stale_snapshot]),
|
||||
patch.object(payloads, "get_live_data_source", return_value=fake_live_source),
|
||||
patch.object(payloads, "_try_collect_real_time_snapshot") as refresh,
|
||||
):
|
||||
result = payloads.build_servers_payload()
|
||||
|
||||
refresh.assert_not_called()
|
||||
data = result["data"]
|
||||
self.assertEqual(result["status"], "ok")
|
||||
self.assertEqual(data["items"][0]["external_server_id"], "comunidad-hispana-01")
|
||||
self.assertEqual(data["refresh_attempted"], False)
|
||||
self.assertEqual(data["refresh_status"], "cache-only")
|
||||
self.assertEqual(data["source"], "persisted-stale-snapshot")
|
||||
|
||||
|
||||
def _build_with_rcon_sample(sample: dict[str, object]) -> dict[str, object]:
|
||||
with (
|
||||
patch("app.payloads.load_rcon_targets", return_value=(TARGET,)),
|
||||
|
||||
@@ -83,6 +83,31 @@ Estado de severidad esperado tras deploy:
|
||||
|
||||
Siguiente fix prioritario actualizado: Fase 2 de `TASK-225` en task separada o continuacion autorizada, centrada en hardening de `/api/current-match/kills` y `/api/current-match/players` sin cambiar configuracion RCON. La validacion final debe repetirse en el entorno donde RCON este local.
|
||||
|
||||
## Estado post-fix TASK-226
|
||||
|
||||
Fecha: 2026-06-10
|
||||
Alcance aplicado: hardening de endpoints publicos RCON/live pendientes, sin cambiar hosts, puertos, `27001`, variables de entorno ni configuracion de servidores.
|
||||
|
||||
Cambios de codigo aplicados:
|
||||
|
||||
- `/api/current-match/kills`: el payload usa AdminLog en modo lectura publica sin inicializar storage (`ensure_storage=False`). Si falta el read model, hay timeout o falla la lectura, devuelve `status: ok`, `items: []`, `confidence: unavailable`, `fallback_used: true` y `fallback_reason` controlado (`admin-log-read-model-unavailable`, `admin-log-read-timeout` o `admin-log-read-failed`).
|
||||
- `/api/current-match/players`: aplica el mismo patron de degradacion controlada sobre player stats de AdminLog. Una excepcion de storage ya no se propaga como 500 sin cuerpo.
|
||||
- `/api/servers`: el builder publico deja de llamar `_try_collect_real_time_snapshot()` y no refresca RCON/A2S en cada GET publico. Sirve snapshots persistidos, marca `freshness`/`is_stale` y expone `refresh_status: "cache-only"`.
|
||||
|
||||
Estado de severidad esperado tras deploy:
|
||||
|
||||
| Endpoint | Severidad TASK-224 | Estado TASK-226 en codigo | Severidad esperada tras deploy |
|
||||
| --- | --- | --- | --- |
|
||||
| `/api/current-match/kills` | CRITICAL | Read-only AdminLog, degradacion JSON controlada | OK o WARNING si falta read model |
|
||||
| `/api/current-match/players` | CRITICAL | Read-only AdminLog, degradacion JSON controlada | OK o WARNING si falta read model |
|
||||
| `/api/servers` | WARNING | Snapshot/cache-only; sin refresh RCON live en request publica | OK o WARNING si snapshot stale |
|
||||
|
||||
Notas:
|
||||
|
||||
- `/api/current-match` conserva deuda separada porque todavia puede intentar muestra RCON directa antes de caer a snapshot.
|
||||
- La frescura de `/api/servers` depende ahora del proceso/runner que mantenga snapshots calientes fuera del request publico.
|
||||
- Auditoria parcial contra produccion ejecutada el 2026-06-10 antes de desplegar este codigo: `current-match` todavia mostro los timeouts/500 previos y `/api/servers` midio 4249.71 ms. Repetir tras deploy para confirmar los tiempos post-fix reales.
|
||||
|
||||
## Evidencia ejecutada
|
||||
|
||||
Comandos ejecutados:
|
||||
|
||||
@@ -6,12 +6,14 @@ Esta auditoria revisa las rutas publicas que alimentan `ranking`, `stats`, `hist
|
||||
|
||||
Actualizacion TASK-225, 2026-06-10: la deuda P1 de `stats search` y `stats player profile` fue corregida en codigo para que los GET publicos usen `player_search_index` y `player_period_stats` en modo read-only estricto, sin inicializar storage ni caer a runtime fallback pesado. La medicion HTTP final requiere redeploy. `current-match` sigue pendiente porque depende de RCON live y debe tratarse como hardening/degradacion sin cambiar hosts, puertos ni configuracion RCON.
|
||||
|
||||
Actualizacion TASK-226, 2026-06-10: `/api/current-match/kills` y `/api/current-match/players` pasan a usar AdminLog en lectura publica sin inicializar storage y con degradacion JSON controlada si el read model no esta disponible o falla. `/api/servers` pasa a ser snapshot/cache-only en el GET publico y ya no dispara refresh RCON/A2S live durante la lectura. La deuda restante de `current-match` queda en `/api/current-match`, que todavia puede intentar una muestra RCON directa antes de caer a snapshot.
|
||||
|
||||
Conclusiones principales:
|
||||
|
||||
- El backend de `ranking` ya no muestra el cuello de botella grave del ranking anual. La evidencia mas fuerte es el test `backend/tests/test_annual_ranking_payload.py`, que confirma que la lectura anual en PostgreSQL ya no inicializa storage en request publico.
|
||||
- El cuello de botella visible actual mas claro esta en frontend: `frontend/assets/js/ranking.js` y `frontend/assets/js/stats.js` bloquean la carga principal detras de `/health`, y `ranking.js` no tiene proteccion contra request race ni limpieza robusta del estado de carga.
|
||||
- `historico.js` es hoy la referencia mas sana del frontend publico: usa snapshots, cache TTL, deduplicacion de peticiones y `requestId` para ignorar respuestas obsoletas.
|
||||
- `partida-actual.js` no bloquea por `/health`, pero hace polling agresivo y paralelo a tres endpoints (`/api/current-match`, `/api/current-match/kills`, `/api/current-match/players`) sin `AbortController`, con intervalos de 1.5 s y 3 s que pueden amplificar carga y re-render innecesario.
|
||||
- `partida-actual.js` no bloquea por `/health`, pero hace polling agresivo y paralelo a tres endpoints (`/api/current-match`, `/api/current-match/kills`, `/api/current-match/players`) sin `AbortController`, con intervalos de 1.5 s y 3 s que pueden amplificar carga y re-render innecesario. Tras TASK-226, kills/players degradan desde backend en JSON controlado.
|
||||
- En backend siguen existiendo fallbacks runtime publicos sobre tablas materializadas grandes para `ranking`, `stats search` y `stats player profile`. Son mejores que consultar RCON directo, pero siguen rompiendo la meta de servir lecturas publicas desde read models dedicados.
|
||||
- Las queries runtime de leaderboard y player stats usan patrones como `COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT))` y agregaciones sobre `rcon_match_player_stats`, lo que aumenta riesgo de scans y de uso parcial de indices.
|
||||
- `current-match` sigue consultando RCON directo en request publico cuando hay target confiable. Eso contradice la regla objetivo de esta auditoria y debe tratarse como deuda arquitectonica explicita aunque hoy sea un requisito funcional de la pagina live.
|
||||
@@ -29,7 +31,7 @@ Flujo observado:
|
||||
|
||||
- `ranking` y `stats` mezclan frontend secuencial con backend que aun puede caer a runtime sobre tablas materializadas si falta snapshot o read model.
|
||||
- `historico` ya prioriza snapshots y fallback controlado.
|
||||
- `current-match` expone una excepcion relevante: consulta RCON directo en la ruta publica cuando encuentra target valido.
|
||||
- `current-match` expone una excepcion relevante: `/api/current-match` consulta RCON directo en la ruta publica cuando encuentra target valido. Kills/players ya leen AdminLog sin inicializar storage en request publico.
|
||||
|
||||
## Inventario de Endpoints Publicos
|
||||
|
||||
@@ -43,9 +45,9 @@ Flujo observado:
|
||||
| `/api/historical/snapshots/recent-matches` | `frontend/assets/js/historico.js` | `build_recent_historical_matches_snapshot_payload()` | Snapshot publico de recent matches | Segun source kind puede usar RCON read model o fallback publico | Si | P2 |
|
||||
| `/api/historical/matches/detail` | `frontend/assets/js/historico-partida.js` | `build_historical_match_detail_payload()` | Read model detalle de partida | Intenta `get_rcon_historical_match_detail()`, luego fallback a storage historico publico | Si | P2 |
|
||||
| `/api/current-match` | `frontend/assets/js/partida-actual.js` | `build_current_match_payload()` | Read model live propio | Primero intenta `_query_current_match_rcon_sample()` directo; luego fallback a `/api/servers` snapshot | Si, y toca RCON directo | P0 |
|
||||
| `/api/current-match/kills` | `frontend/assets/js/partida-actual.js` | `build_current_match_kill_feed_payload()` | Read model live propio de kill feed | `list_current_match_kill_feed()` desde evidencia AdminLog materializada | No se observo fallback a scoreboard | P1 |
|
||||
| `/api/current-match/players` | `frontend/assets/js/partida-actual.js` | `build_current_match_player_stats_payload()` | Read model live propio de player stats | `list_current_match_player_stats()` desde evidencia AdminLog materializada | No se observo fallback a scoreboard | P1 |
|
||||
| `/api/servers` | `frontend/assets/js/main.js`, fallback de current-match | `build_servers_payload()` | Snapshot live de servidores | Snapshot local/live payload | Sin fallback costoso visible | P2 |
|
||||
| `/api/current-match/kills` | `frontend/assets/js/partida-actual.js` | `build_current_match_kill_feed_payload()` | Read model live propio de kill feed | AdminLog materializado en modo read-only publico | Degradacion JSON controlada si falla read model | P2 |
|
||||
| `/api/current-match/players` | `frontend/assets/js/partida-actual.js` | `build_current_match_player_stats_payload()` | Read model live propio de player stats | AdminLog materializado en modo read-only publico | Degradacion JSON controlada si falla read model | P2 |
|
||||
| `/api/servers` | `frontend/assets/js/main.js`, fallback de current-match | `build_servers_payload()` | Snapshot live de servidores | Snapshot/cache persistido | Sin refresh live en GET publico | P2 |
|
||||
| `/health` | `frontend/assets/js/main.js`, `ranking.js`, `stats.js` | `build_health_payload()` | N/A | Check tecnico | No aplica | P1 por bloqueo UI, no por backend |
|
||||
|
||||
Notas:
|
||||
|
||||
Reference in New Issue
Block a user