Complete TASK-071 public scoreboard adapter
This commit is contained in:
66
ai/tasks/done/TASK-071-public-scoreboard-provider-adapter.md
Normal file
66
ai/tasks/done/TASK-071-public-scoreboard-provider-adapter.md
Normal 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.
|
||||||
@@ -247,12 +247,15 @@ Defaults actuales:
|
|||||||
- `HLL_BACKEND_LIVE_DATA_SOURCE=a2s`
|
- `HLL_BACKEND_LIVE_DATA_SOURCE=a2s`
|
||||||
- `HLL_BACKEND_HISTORICAL_DATA_SOURCE=public-scoreboard`
|
- `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`
|
- `get_live_data_source()` entrega el proveedor usado por `payloads.py`
|
||||||
cuando `/api/servers` necesita un refresh real
|
cuando `/api/servers` necesita un refresh real
|
||||||
- `get_historical_data_source()` entrega el proveedor usado por
|
- `get_historical_data_source()` entrega el proveedor usado por
|
||||||
`historical_ingestion.py` para bootstrap y refresh incremental
|
`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:
|
En esta task solo queda implementado el proveedor ya operativo de desarrollo:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Protocol
|
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 .collector import collect_server_snapshots
|
||||||
from .config import (
|
from .config import get_historical_data_source_kind, get_live_data_source_kind
|
||||||
get_historical_crcon_request_retries,
|
from .providers.public_scoreboard_provider import PublicScoreboardHistoricalDataSource
|
||||||
get_historical_crcon_request_timeout_seconds,
|
|
||||||
get_historical_crcon_retry_delay_seconds,
|
|
||||||
get_historical_data_source_kind,
|
|
||||||
get_live_data_source_kind,
|
|
||||||
)
|
|
||||||
from .server_targets import A2SServerTarget, load_a2s_targets
|
from .server_targets import A2SServerTarget, load_a2s_targets
|
||||||
|
|
||||||
|
|
||||||
HISTORICAL_SOURCE_PUBLIC_SCOREBOARD = "public-scoreboard"
|
|
||||||
LIVE_SOURCE_A2S = "a2s"
|
LIVE_SOURCE_A2S = "a2s"
|
||||||
SOURCE_KIND_RCON = "rcon"
|
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):
|
class HistoricalDataSource(Protocol):
|
||||||
"""Contract for historical providers used by ingestion flows."""
|
"""Contract for historical providers used by ingestion flows."""
|
||||||
@@ -64,115 +48,6 @@ class LiveDataSource(Protocol):
|
|||||||
"""Return optional server connection metadata keyed by external id."""
|
"""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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class A2SLiveDataSource:
|
class A2SLiveDataSource:
|
||||||
"""Live provider backed by the existing A2S collector flow."""
|
"""Live provider backed by the existing A2S collector flow."""
|
||||||
@@ -232,7 +107,7 @@ class RconLiveDataSource:
|
|||||||
def get_historical_data_source() -> HistoricalDataSource:
|
def get_historical_data_source() -> HistoricalDataSource:
|
||||||
"""Select the historical provider configured for the current environment."""
|
"""Select the historical provider configured for the current environment."""
|
||||||
source_kind = get_historical_data_source_kind()
|
source_kind = get_historical_data_source_kind()
|
||||||
if source_kind == HISTORICAL_SOURCE_PUBLIC_SCOREBOARD:
|
if source_kind == "public-scoreboard":
|
||||||
return PublicScoreboardHistoricalDataSource()
|
return PublicScoreboardHistoricalDataSource()
|
||||||
if source_kind == SOURCE_KIND_RCON:
|
if source_kind == SOURCE_KIND_RCON:
|
||||||
return RconHistoricalDataSource()
|
return RconHistoricalDataSource()
|
||||||
@@ -247,11 +122,3 @@ def get_live_data_source() -> LiveDataSource:
|
|||||||
if source_kind == SOURCE_KIND_RCON:
|
if source_kind == SOURCE_KIND_RCON:
|
||||||
return RconLiveDataSource()
|
return RconLiveDataSource()
|
||||||
raise ValueError(f"Unsupported live data source: {source_kind}")
|
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")
|
|
||||||
|
|||||||
139
backend/app/providers/public_scoreboard_provider.py
Normal file
139
backend/app/providers/public_scoreboard_provider.py
Normal 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")
|
||||||
Reference in New Issue
Block a user