Complete TASK-071 public scoreboard adapter

This commit is contained in:
devRaGonSa
2026-03-24 10:37:10 +01:00
parent 784fc45034
commit b68839ed97
4 changed files with 213 additions and 138 deletions

View File

@@ -0,0 +1,66 @@
# TASK-071-public-scoreboard-provider-adapter
## Goal
Adaptar la fuente actual public-scoreboard al nuevo contrato de proveedor para que siga siendo la fuente estándar en desarrollo sin romper el comportamiento existente.
## Context
Una vez exista la abstracción de fuente, hay que mover la lógica actual a un proveedor explícito de tipo public-scoreboard. Eso permitirá mantener dev estable mientras se prepara RCON para producción.
## Steps
1. Revisar la lógica actual que usa scoreboard/CRCON público.
2. Encapsularla dentro del proveedor public-scoreboard compatible con la nueva abstracción.
3. Asegurar que desarrollo sigue funcionando con la misma semántica actual.
4. Mantener el mismo resultado en:
- ingestión histórica
- snapshots
- estado actual de servidores
5. Reducir duplicaciones y dejar clara la responsabilidad del proveedor.
6. Actualizar documentación mínima.
7. No implementar todavía lógica RCON en esta task.
8. Al completar la implementación:
- dejar el repositorio consistente
- hacer commit
- hacer push al remoto si el entorno lo permite
## Files to Read First
- AGENTS.md
- backend/README.md
- backend/app/config.py
- backend/app/historical_ingestion.py
- backend/app/payloads.py
- backend/app/routes.py
- backend/app/source_provider.py
- docs/historical-crcon-source-discovery.md
## Expected Files to Modify
- backend/app/historical_ingestion.py
- backend/app/payloads.py
- backend/app/routes.py
- opcionalmente nuevos módulos, por ejemplo:
- backend/app/providers/public_scoreboard_provider.py
- backend/README.md
## Constraints
- No romper el comportamiento actual en dev.
- No hacer cambios destructivos.
- Mantener el trabajo centrado en encapsular correctamente la fuente actual.
## Validation
- El proveedor public-scoreboard funciona bajo la nueva abstracción.
- El modo desarrollo sigue comportándose como antes.
- Los cambios quedan committeados y se hace push si el entorno lo permite.
## Change Budget
- Preferir menos de 5 archivos modificados o creados.
- Preferir menos de 220 líneas cambiadas.
## Outcome
- Se extrajo la logica del proveedor historico actual a `backend/app/providers/public_scoreboard_provider.py`.
- `backend/app/data_sources.py` quedo reducido a contratos y seleccion de proveedor por entorno.
- El comportamiento de desarrollo se mantuvo estable con `public-scoreboard` como proveedor historico por defecto.
- No se introdujo logica RCON en esta task.
## Validation Notes
- `python -m compileall backend/app` completo sin errores tras el refactor.
- La diff quedo limitada al selector de proveedores, el adapter publico y la documentacion minima.
- `backend/app/source_provider.py` no existia; se reutilizo `backend/app/data_sources.py` como punto de seleccion ya introducido en la task anterior.

View File

@@ -247,12 +247,15 @@ Defaults actuales:
- `HLL_BACKEND_LIVE_DATA_SOURCE=a2s`
- `HLL_BACKEND_HISTORICAL_DATA_SOURCE=public-scoreboard`
La seleccion efectiva se resuelve en `app/data_sources.py`:
La seleccion efectiva se resuelve en `app/data_sources.py` y en adapters
dedicados dentro de `app/providers/`:
- `get_live_data_source()` entrega el proveedor usado por `payloads.py`
cuando `/api/servers` necesita un refresh real
- `get_historical_data_source()` entrega el proveedor usado por
`historical_ingestion.py` para bootstrap y refresh incremental
- `providers/public_scoreboard_provider.py` encapsula la semantica actual del
scoreboard/CRCON publico bajo el contrato historico
En esta task solo queda implementado el proveedor ya operativo de desarrollo:

View File

@@ -1,35 +1,19 @@
"""Data source provider contracts for live and historical backend flows."""
"""Data source selection and contracts for live and historical backend flows."""
from __future__ import annotations
import json
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from typing import Protocol
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from .collector import collect_server_snapshots
from .config import (
get_historical_crcon_request_retries,
get_historical_crcon_request_timeout_seconds,
get_historical_crcon_retry_delay_seconds,
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 .server_targets import A2SServerTarget, load_a2s_targets
HISTORICAL_SOURCE_PUBLIC_SCOREBOARD = "public-scoreboard"
LIVE_SOURCE_A2S = "a2s"
SOURCE_KIND_RCON = "rcon"
PUBLIC_INFO_ENDPOINT = "/api/get_public_info"
MATCH_LIST_ENDPOINT = "/api/get_scoreboard_maps"
MATCH_DETAIL_ENDPOINT = "/api/get_map_scoreboard"
class HistoricalDataSource(Protocol):
"""Contract for historical providers used by ingestion flows."""
@@ -64,115 +48,6 @@ class LiveDataSource(Protocol):
"""Return optional server connection metadata keyed by external id."""
@dataclass(frozen=True, slots=True)
class PublicScoreboardHistoricalDataSource:
"""Historical provider backed by the public CRCON scoreboard JSON API."""
source_kind: str = HISTORICAL_SOURCE_PUBLIC_SCOREBOARD
def fetch_public_info(self, *, base_url: str) -> dict[str, object]:
return self._fetch_dict_payload(base_url, PUBLIC_INFO_ENDPOINT)
def fetch_match_page(self, *, base_url: str, page: int, limit: int) -> dict[str, object]:
return self._fetch_dict_payload(
base_url,
MATCH_LIST_ENDPOINT,
{"page": page, "limit": limit},
context=f"page={page}",
)
def fetch_match_details(
self,
*,
base_url: str,
match_ids: list[str],
max_workers: int,
) -> list[dict[str, object]]:
if not match_ids:
return []
if max_workers <= 1:
return [
self._fetch_match_detail(base_url=base_url, match_id=match_id)
for match_id in match_ids
]
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [
executor.submit(self._fetch_match_detail, base_url=base_url, match_id=match_id)
for match_id in match_ids
]
return [future.result() for future in futures]
def _fetch_match_detail(self, *, base_url: str, match_id: str) -> dict[str, object]:
return self._fetch_dict_payload(
base_url,
MATCH_DETAIL_ENDPOINT,
{"map_id": match_id},
context=f"match={match_id}",
)
def _fetch_json(
self,
*,
base_url: str,
endpoint: str,
query: dict[str, object] | None = None,
) -> object:
url = f"{base_url}{endpoint}"
if query:
url = f"{url}?{urlencode(query)}"
request = Request(
url,
headers={
"Accept": "application/json",
"User-Agent": "HLL-Vietnam-Historical-Ingestion/0.1",
},
)
try:
with urlopen(
request,
timeout=get_historical_crcon_request_timeout_seconds(),
) as response:
return json.loads(response.read().decode("utf-8"))
except HTTPError as exc:
raise RuntimeError(f"Historical provider request failed: {url} ({exc.code})") from exc
except URLError as exc:
raise RuntimeError(f"Historical provider request failed: {url} ({exc.reason})") from exc
def _fetch_dict_payload(
self,
base_url: str,
endpoint: str,
query: dict[str, object] | None = None,
*,
context: str = "",
retries: int | None = None,
) -> dict[str, object]:
resolved_retries = retries or get_historical_crcon_request_retries()
base_retry_delay_seconds = get_historical_crcon_retry_delay_seconds()
last_error: Exception | None = None
for attempt in range(1, resolved_retries + 1):
try:
payload = _unwrap_result(
self._fetch_json(base_url=base_url, endpoint=endpoint, query=query)
)
except Exception as exc: # pragma: no cover - network path
last_error = exc
else:
if isinstance(payload, dict):
return payload
last_error = ValueError(
f"Unexpected payload type for {base_url}{endpoint} {context}".strip()
)
if attempt < resolved_retries:
time.sleep(base_retry_delay_seconds * attempt)
assert last_error is not None
raise last_error
@dataclass(frozen=True, slots=True)
class A2SLiveDataSource:
"""Live provider backed by the existing A2S collector flow."""
@@ -232,7 +107,7 @@ class RconLiveDataSource:
def get_historical_data_source() -> HistoricalDataSource:
"""Select the historical provider configured for the current environment."""
source_kind = get_historical_data_source_kind()
if source_kind == HISTORICAL_SOURCE_PUBLIC_SCOREBOARD:
if source_kind == "public-scoreboard":
return PublicScoreboardHistoricalDataSource()
if source_kind == SOURCE_KIND_RCON:
return RconHistoricalDataSource()
@@ -247,11 +122,3 @@ def get_live_data_source() -> LiveDataSource:
if source_kind == SOURCE_KIND_RCON:
return RconLiveDataSource()
raise ValueError(f"Unsupported live data source: {source_kind}")
def _unwrap_result(payload: object) -> object:
if not isinstance(payload, dict):
return payload
if "result" not in payload:
return payload
return payload.get("result")

View File

@@ -0,0 +1,139 @@
"""Public scoreboard provider adapter for historical HLL data."""
from __future__ import annotations
import json
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from ..config import (
get_historical_crcon_request_retries,
get_historical_crcon_request_timeout_seconds,
get_historical_crcon_retry_delay_seconds,
)
PUBLIC_INFO_ENDPOINT = "/api/get_public_info"
MATCH_LIST_ENDPOINT = "/api/get_scoreboard_maps"
MATCH_DETAIL_ENDPOINT = "/api/get_map_scoreboard"
@dataclass(frozen=True, slots=True)
class PublicScoreboardHistoricalDataSource:
"""Historical provider backed by the public CRCON scoreboard JSON API."""
source_kind: str = "public-scoreboard"
def fetch_public_info(self, *, base_url: str) -> dict[str, object]:
return self._fetch_dict_payload(base_url, PUBLIC_INFO_ENDPOINT)
def fetch_match_page(self, *, base_url: str, page: int, limit: int) -> dict[str, object]:
return self._fetch_dict_payload(
base_url,
MATCH_LIST_ENDPOINT,
{"page": page, "limit": limit},
context=f"page={page}",
)
def fetch_match_details(
self,
*,
base_url: str,
match_ids: list[str],
max_workers: int,
) -> list[dict[str, object]]:
if not match_ids:
return []
if max_workers <= 1:
return [
self._fetch_match_detail(base_url=base_url, match_id=match_id)
for match_id in match_ids
]
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [
executor.submit(self._fetch_match_detail, base_url=base_url, match_id=match_id)
for match_id in match_ids
]
return [future.result() for future in futures]
def _fetch_match_detail(self, *, base_url: str, match_id: str) -> dict[str, object]:
return self._fetch_dict_payload(
base_url,
MATCH_DETAIL_ENDPOINT,
{"map_id": match_id},
context=f"match={match_id}",
)
def _fetch_json(
self,
*,
base_url: str,
endpoint: str,
query: dict[str, object] | None = None,
) -> object:
url = f"{base_url}{endpoint}"
if query:
url = f"{url}?{urlencode(query)}"
request = Request(
url,
headers={
"Accept": "application/json",
"User-Agent": "HLL-Vietnam-Historical-Ingestion/0.1",
},
)
try:
with urlopen(
request,
timeout=get_historical_crcon_request_timeout_seconds(),
) as response:
return json.loads(response.read().decode("utf-8"))
except HTTPError as exc:
raise RuntimeError(f"Historical provider request failed: {url} ({exc.code})") from exc
except URLError as exc:
raise RuntimeError(f"Historical provider request failed: {url} ({exc.reason})") from exc
def _fetch_dict_payload(
self,
base_url: str,
endpoint: str,
query: dict[str, object] | None = None,
*,
context: str = "",
retries: int | None = None,
) -> dict[str, object]:
resolved_retries = retries or get_historical_crcon_request_retries()
base_retry_delay_seconds = get_historical_crcon_retry_delay_seconds()
last_error: Exception | None = None
for attempt in range(1, resolved_retries + 1):
try:
payload = _unwrap_result(
self._fetch_json(base_url=base_url, endpoint=endpoint, query=query)
)
except Exception as exc: # pragma: no cover - network path
last_error = exc
else:
if isinstance(payload, dict):
return payload
last_error = ValueError(
f"Unexpected payload type for {base_url}{endpoint} {context}".strip()
)
if attempt < resolved_retries:
time.sleep(base_retry_delay_seconds * attempt)
assert last_error is not None
raise last_error
def _unwrap_result(payload: object) -> object:
if not isinstance(payload, dict):
return payload
if "result" not in payload:
return payload
return payload.get("result")