Implement rcon-first historical ingestion path

This commit is contained in:
devRaGonSa
2026-03-25 21:38:02 +01:00
parent 6ed416f79a
commit cdcd4523b4
6 changed files with 547 additions and 185 deletions

View File

@@ -0,0 +1,53 @@
# TASK-100-rcon-historical-writer-path-implementation
## Goal
Implementar un writer path histórico real por RCON para que la ingesta histórica intente RCON primero y use scoreboard/public-scoreboard solo como fallback.
## Context
La repo ya tiene:
- live RCON-first
- captura prospectiva RCON
- read model histórico RCON parcial
- ingesta histórica clásica por scoreboard
Pero todavía no existe un writer path histórico real por RCON integrado en `historical_ingestion`.
## Steps
1. Auditar:
- `backend/app/data_sources.py`
- `backend/app/historical_ingestion.py`
- `backend/app/rcon_historical_worker.py`
- `backend/app/rcon_historical_storage.py`
- `backend/app/rcon_historical_read_model.py`
- cualquier capa necesaria de modelos/storage
2. Definir qué significa “writer path histórico RCON” con la telemetría real actual:
- qué puede alimentar
- qué estructura persistida necesita
- cómo se integra con el ingestion flow existente
3. Implementar una vía writer-oriented RCON que permita a `historical_ingestion` intentar primero RCON.
4. Si RCON falla o no cubre la operación concreta, hacer fallback controlado a scoreboard/public-scoreboard.
5. Mantener trazabilidad explícita:
- primary_source
- selected_source
- fallback_used
- fallback_reason
- source_attempts
6. No romper la compatibilidad con snapshots ni con rebuilds posteriores.
7. Actualizar README/runbook explicando el nuevo writer path real.
## Constraints
- No fingir cobertura histórica RCON que no exista.
- No eliminar scoreboard como fallback.
- No romper live RCON-first.
- No romper el locking compartido.
## Validation
- `historical_ingestion` intenta RCON primero.
- Cuando RCON falla o no soporta la operación, hace fallback explícito a scoreboard.
- La salida del comando deja claro qué fuente se usó realmente.
- El repositorio queda consistente.
## Expected Files
- `backend/app/data_sources.py`
- `backend/app/historical_ingestion.py`
- archivos backend necesarios para writer path RCON
- `backend/README.md`

View File

@@ -0,0 +1,38 @@
# TASK-101-remove-false-rcon-first-claims-and-fix-operational-visibility
## Goal
Alinear documentación, outputs operativos y visibilidad de progreso con el comportamiento real del sistema, evitando afirmaciones engañosas sobre histórico RCON-first y mejorando la operativa del refresh manual.
## Context
Actualmente el operador puede pensar que la ingesta histórica ya va por RCON cuando en realidad el writer path sigue cayendo a scoreboard.
Además, el comando de refresh es demasiado opaco: tarda mucho y no ofrece progreso útil.
## Steps
1. Auditar:
- `backend/README.md`
- `backend/app/historical_ingestion.py`
- outputs/logs relevantes
2. Corregir cualquier copy/documentación que sugiera que la ingesta histórica completa ya está en RCON si no es cierto.
3. Añadir progreso operativo útil al refresh manual:
- servidor actual
- página actual
- número de match ids a detallar
- fuente realmente seleccionada
4. Hacer que, cuando haya fallback a scoreboard, eso quede visible para el operador en tiempo real o en el payload final.
5. Mantener la salida usable, sin inundar de logs innecesarios.
6. Actualizar runbook con recomendaciones reales para pasadas manuales y límites razonables.
## Constraints
- No convertir el comando en un spam de logs.
- No ocultar fallbacks reales.
- No mezclar esta task con grandes cambios de UI frontend.
## Validation
- El operador puede ver progreso útil durante un refresh.
- La fuente usada de verdad queda visible.
- La documentación ya no induce a error.
- El repositorio queda consistente.
## Expected Files
- `backend/app/historical_ingestion.py`
- `backend/README.md`

View File

@@ -272,7 +272,7 @@ Valores soportados en esta fase:
- `rcon` como camino primario recomendado - `rcon` como camino primario recomendado
- `a2s` como fallback legacy o override explicito - `a2s` como fallback legacy o override explicito
- historico: - historico:
- `rcon` como camino primario recomendado para captura y lectura minima - `rcon` como camino primario recomendado para captura y writer path primario
- `public-scoreboard` como fallback legacy o override explicito - `public-scoreboard` como fallback legacy o override explicito
Defaults actuales: Defaults actuales:
@@ -309,18 +309,14 @@ Politica funcional actual:
- `public-scoreboard` solo si RCON no soporta aun esa operacion concreta o - `public-scoreboard` solo si RCON no soporta aun esa operacion concreta o
falla la captura primaria falla la captura primaria
Limitacion actual de `rcon`:
- el backend puede usar `rcon` para `/api/servers`
- la ingesta historica competitiva por `historical_ingestion.py` sigue cayendo a
`public-scoreboard`, porque la repo todavia no incluye una canalizacion
retroactiva RCON capaz de reconstruir partidas cerradas con paridad completa
Estado real de "historico por RCON" en esta repo: Estado real de "historico por RCON" en esta repo:
- no existe backfill retroactivo por RCON con el cliente actual - no existe backfill retroactivo por RCON con el cliente actual
- la viabilidad documentada hoy es solo para captura prospectiva separada - la viabilidad documentada hoy es solo para captura prospectiva separada
- `public-scoreboard` sigue siendo la fuente historica principal - `historical_ingestion.py` intenta primero el writer path prospectivo RCON
- la persistencia competitiva `historical_*` sigue necesitando fallback a
`public-scoreboard` mientras RCON no exponga pagina historica/detalle de
match cerrada con paridad suficiente
- el diseno tecnico de esa linea prospectiva queda en - el diseno tecnico de esa linea prospectiva queda en
`docs/rcon-historical-ingestion-design.md` `docs/rcon-historical-ingestion-design.md`
@@ -473,7 +469,8 @@ respuesta deja trazabilidad con `primary_source`, `selected_source`,
consultas live de produccion. consultas live de produccion.
- `config.py` centraliza host, puerto y allowlist minima de origenes locales. - `config.py` centraliza host, puerto y allowlist minima de origenes locales.
- `data_sources.py` define los contratos y la seleccion por entorno para live e historico. - `data_sources.py` define los contratos y la seleccion por entorno para live e historico.
- `historical_ingestion.py` consulta la capa JSON publica de CRCON para bootstrap y refresh incremental. - `historical_ingestion.py` intenta primero el writer path RCON y, si hace falta
poblar `historical_*`, cae de forma explicita a la capa JSON publica de CRCON.
- `historical_models.py` fija las entidades historicas minimas del dominio. - `historical_models.py` fija las entidades historicas minimas del dominio.
- `historical_snapshots.py` fija los tipos y selectores validos de snapshots historicos precalculados. - `historical_snapshots.py` fija los tipos y selectores validos de snapshots historicos precalculados.
- `historical_snapshot_storage.py` persiste snapshots historicos precalculados listos para lectura rapida. - `historical_snapshot_storage.py` persiste snapshots historicos precalculados listos para lectura rapida.
@@ -1039,6 +1036,25 @@ Si la recomposicion se lanza para un servidor fisico concreto, el backend
rehace tambien el agregado logico `all-servers` para mantener `Todos` rehace tambien el agregado logico `all-servers` para mantener `Todos`
alineado con `#01` y `#02` aunque `#03` siga sin bootstrap. alineado con `#01` y `#02` aunque `#03` siga sin bootstrap.
En esta fase, el comando muestra progreso operativo util sin saturar stdout:
- intento primario RCON
- fuente finalmente seleccionada
- servidor actual
- pagina actual
- `match_ids_to_detail` de cada pagina
Si RCON no puede cubrir la operacion competitiva real, el fallback a
`public-scoreboard` queda visible tanto durante la ejecucion como en el JSON
final mediante:
- `primary_source`
- `selected_source`
- `fallback_used`
- `fallback_reason`
- `source_attempts`
- `primary_writer_result`
El comando devuelve ademas un resumen de cobertura persistida por servidor. Esto El comando devuelve ademas un resumen de cobertura persistida por servidor. Esto
ayuda a validar rapidamente cuantos matches reales quedaron importados, el rango ayuda a validar rapidamente cuantos matches reales quedaron importados, el rango
temporal cubierto y si la carga ya supera la ultima semana movil que usa la UI. temporal cubierto y si la carga ya supera la ultima semana movil que usa la UI.
@@ -1080,6 +1096,16 @@ registrados. La segunda sirve para validar un solo servidor con alcance
acotado. La tercera recompone snapshots despues de una pasada manual cuando se acotado. La tercera recompone snapshots despues de una pasada manual cuando se
quiere confirmar que la capa precalculada vuelve a quedar alineada. quiere confirmar que la capa precalculada vuelve a quedar alineada.
Interpretacion operativa recomendada:
- si aparece `historical-ingestion-rcon-primary-succeeded`, RCON se intento de
verdad primero y la captura prospectiva quedo registrada
- si despues `selected_source` termina en `public-scoreboard`, eso significa
que la reconstruccion del archivo competitivo `historical_*` siguio
necesitando fallback clasico
- si RCON falla por red, auth o timeout, el motivo queda visible en
`fallback_reason` y en `source_attempts`
Los reintentos de cada request JSON pueden ajustarse sin tocar codigo con: Los reintentos de cada request JSON pueden ajustarse sin tocar codigo con:
- `HLL_HISTORICAL_CRCON_REQUEST_RETRIES` - `HLL_HISTORICAL_CRCON_REQUEST_RETRIES`
@@ -1392,9 +1418,11 @@ El backend queda orientado a `RCON-first` tambien para historico:
- `rcon` primero - `rcon` primero
- `a2s` solo como fallback - `a2s` solo como fallback
- historico: - historico:
- `rcon` primero para la capa de lectura/cobertura soportada hoy - `rcon` primero tanto para lectura minima como para el writer path primario
de `historical_ingestion`
- `public-scoreboard` solo como fallback cuando RCON no cubre una operacion - `public-scoreboard` solo como fallback cuando RCON no cubre una operacion
competitiva concreta o no tiene cobertura suficiente competitiva concreta, no tiene cobertura suficiente o falla la captura
primaria
Metadata observable en payloads historicos: Metadata observable en payloads historicos:
@@ -1407,6 +1435,8 @@ Metadata observable en payloads historicos:
Estado real a fecha de esta fase: Estado real a fecha de esta fase:
- el read model historico RCON soporta cobertura y actividad reciente - el read model historico RCON soporta cobertura y actividad reciente
- `historical_ingestion` intenta primero una captura writer-oriented por RCON y
deja esa tentativa visible en su salida
- rankings competitivos, MVP y Elo/MMR siguen necesitando fallback a - rankings competitivos, MVP y Elo/MMR siguen necesitando fallback a
`public-scoreboard` porque el read model RCON actual no expone aun detalle `public-scoreboard` porque el read model RCON actual no expone aun detalle
historico competitivo suficiente historico competitivo suficiente

View File

@@ -327,45 +327,46 @@ def build_historical_runtime_source_policy(
def resolve_historical_ingestion_data_source() -> tuple[HistoricalDataSource, dict[str, object]]: def resolve_historical_ingestion_data_source() -> tuple[HistoricalDataSource, dict[str, object]]:
"""Resolve the writer-oriented historical provider with safe fallback semantics.""" """Resolve the fallback provider used when classic scoreboard import is required."""
configured_kind = get_historical_data_source_kind() configured_kind = get_historical_data_source_kind()
if configured_kind == SOURCE_KIND_PUBLIC_SCOREBOARD: if configured_kind in {SOURCE_KIND_PUBLIC_SCOREBOARD, SOURCE_KIND_RCON}:
return ( primary_source = (
PublicScoreboardHistoricalDataSource(), SOURCE_KIND_PUBLIC_SCOREBOARD
build_source_policy( if configured_kind == SOURCE_KIND_PUBLIC_SCOREBOARD
primary_source=SOURCE_KIND_PUBLIC_SCOREBOARD, else SOURCE_KIND_RCON
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
source_attempts=[
build_source_attempt(
source=SOURCE_KIND_PUBLIC_SCOREBOARD,
role="primary",
status="success",
) )
], fallback_used = configured_kind == SOURCE_KIND_RCON
), fallback_reason = (
"classic-historical-import-requires-public-scoreboard-fallback"
if fallback_used
else None
) )
attempts = []
if configured_kind == SOURCE_KIND_RCON: if configured_kind == SOURCE_KIND_RCON:
return ( attempts.append(
PublicScoreboardHistoricalDataSource(),
build_source_policy(
primary_source=SOURCE_KIND_RCON,
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
fallback_used=True,
fallback_reason="rcon-historical-ingestion-not-supported-yet",
source_attempts=[
build_source_attempt( build_source_attempt(
source=SOURCE_KIND_RCON, source=SOURCE_KIND_RCON,
role="primary", role="primary",
status="unsupported", status="deferred",
reason="rcon-historical-ingestion-not-supported-yet", reason="rcon-primary-writer-attempt-is-handled-by-historical-ingestion",
), )
)
attempts.append(
build_source_attempt( build_source_attempt(
source=SOURCE_KIND_PUBLIC_SCOREBOARD, source=SOURCE_KIND_PUBLIC_SCOREBOARD,
role="fallback", role="fallback" if fallback_used else "primary",
status="success", status="ready",
), reason="classic-historical-import-provider-ready",
], )
)
return (
PublicScoreboardHistoricalDataSource(),
build_source_policy(
primary_source=primary_source,
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
fallback_used=fallback_used,
fallback_reason=fallback_reason,
source_attempts=attempts,
), ),
) )

View File

@@ -5,14 +5,21 @@ from __future__ import annotations
import argparse import argparse
import json import json
from dataclasses import dataclass from dataclasses import dataclass
from typing import Iterable from typing import Callable, Iterable
from .config import ( from .config import (
get_historical_crcon_detail_workers, get_historical_crcon_detail_workers,
get_historical_crcon_page_size, get_historical_crcon_page_size,
get_historical_data_source_kind,
get_historical_refresh_overlap_hours, get_historical_refresh_overlap_hours,
) )
from .data_sources import HistoricalDataSource, resolve_historical_ingestion_data_source from .data_sources import (
SOURCE_KIND_PUBLIC_SCOREBOARD,
SOURCE_KIND_RCON,
HistoricalDataSource,
build_historical_runtime_source_policy,
resolve_historical_ingestion_data_source,
)
from .elo_mmr_engine import rebuild_elo_mmr_models from .elo_mmr_engine import rebuild_elo_mmr_models
from .historical_snapshots import generate_and_persist_historical_snapshots from .historical_snapshots import generate_and_persist_historical_snapshots
from .historical_storage import ( from .historical_storage import (
@@ -28,9 +35,13 @@ from .historical_storage import (
start_ingestion_run, start_ingestion_run,
upsert_historical_match, upsert_historical_match,
) )
from .rcon_historical_worker import run_rcon_historical_capture_unlocked
from .writer_lock import backend_writer_lock, build_writer_lock_holder from .writer_lock import backend_writer_lock, build_writer_lock_holder
ProgressCallback = Callable[[dict[str, object]], None]
@dataclass(slots=True) @dataclass(slots=True)
class IngestionStats: class IngestionStats:
"""Mutable counters for one ingestion execution.""" """Mutable counters for one ingestion execution."""
@@ -57,6 +68,7 @@ def run_bootstrap(
start_page: int | None = None, start_page: int | None = None,
detail_workers: int | None = None, detail_workers: int | None = None,
rebuild_snapshots: bool = True, rebuild_snapshots: bool = True,
progress_callback: ProgressCallback | None = None,
) -> dict[str, object]: ) -> dict[str, object]:
"""Run a first full historical import against one or all configured servers.""" """Run a first full historical import against one or all configured servers."""
with backend_writer_lock( with backend_writer_lock(
@@ -74,6 +86,7 @@ def run_bootstrap(
overlap_hours=None, overlap_hours=None,
incremental=False, incremental=False,
rebuild_snapshots=rebuild_snapshots, rebuild_snapshots=rebuild_snapshots,
progress_callback=progress_callback,
) )
@@ -86,6 +99,7 @@ def run_incremental_refresh(
detail_workers: int | None = None, detail_workers: int | None = None,
overlap_hours: int | None = None, overlap_hours: int | None = None,
rebuild_snapshots: bool = True, rebuild_snapshots: bool = True,
progress_callback: ProgressCallback | None = None,
) -> dict[str, object]: ) -> dict[str, object]:
"""Refresh recent historical pages without replaying the whole archive.""" """Refresh recent historical pages without replaying the whole archive."""
with backend_writer_lock( with backend_writer_lock(
@@ -103,6 +117,7 @@ def run_incremental_refresh(
overlap_hours=overlap_hours, overlap_hours=overlap_hours,
incremental=True, incremental=True,
rebuild_snapshots=rebuild_snapshots, rebuild_snapshots=rebuild_snapshots,
progress_callback=progress_callback,
) )
@@ -117,10 +132,11 @@ def _run_ingestion(
overlap_hours: int | None, overlap_hours: int | None,
incremental: bool, incremental: bool,
rebuild_snapshots: bool, rebuild_snapshots: bool,
progress_callback: ProgressCallback | None,
) -> dict[str, object]: ) -> dict[str, object]:
initialize_historical_storage() initialize_historical_storage()
stats = IngestionStats() stats = IngestionStats()
data_source, source_policy = resolve_historical_ingestion_data_source() fallback_data_source, fallback_source_policy = resolve_historical_ingestion_data_source()
selected_servers = _select_servers(server_slug) selected_servers = _select_servers(server_slug)
processed_servers: list[dict[str, object]] = [] processed_servers: list[dict[str, object]] = []
active_runs: dict[str, int] = {} active_runs: dict[str, int] = {}
@@ -132,7 +148,31 @@ def _run_ingestion(
if resolved_overlap_hours < 0: if resolved_overlap_hours < 0:
raise ValueError("--overlap-hours must be zero or positive.") raise ValueError("--overlap-hours must be zero or positive.")
primary_writer_result = _attempt_primary_rcon_writer(
mode=mode,
server_slug=server_slug,
selected_servers=selected_servers,
progress_callback=progress_callback,
)
source_policy = _resolve_ingestion_source_policy(
fallback_source_policy=fallback_source_policy,
primary_writer_result=primary_writer_result,
)
use_classic_fallback = _should_use_classic_fallback(primary_writer_result)
_emit_progress(
progress_callback,
{
"event": "historical-ingestion-source-selected",
"mode": mode,
"primary_source": source_policy.get("primary_source"),
"selected_source": source_policy.get("selected_source"),
"fallback_used": bool(source_policy.get("fallback_used")),
"fallback_reason": source_policy.get("fallback_reason"),
},
)
try: try:
if use_classic_fallback:
for server in selected_servers: for server in selected_servers:
run_id = start_ingestion_run(mode=mode, target_server_slug=str(server["slug"])) run_id = start_ingestion_run(mode=mode, target_server_slug=str(server["slug"]))
active_runs[str(server["slug"])] = run_id active_runs[str(server["slug"])] = run_id
@@ -159,12 +199,14 @@ def _run_ingestion(
mode=mode, mode=mode,
run_id=run_id, run_id=run_id,
stats=stats, stats=stats,
data_source=data_source, data_source=fallback_data_source,
max_pages=max_pages, max_pages=max_pages,
page_size=page_size, page_size=page_size,
start_page=resolved_start_page, start_page=resolved_start_page,
detail_workers=detail_workers, detail_workers=detail_workers,
cutoff=cutoff, cutoff=cutoff,
progress_callback=progress_callback,
source_policy=source_policy,
) )
processed_servers.append(server_stats) processed_servers.append(server_stats)
finalize_ingestion_run( finalize_ingestion_run(
@@ -224,8 +266,9 @@ def _run_ingestion(
return { return {
"status": "ok", "status": "ok",
"mode": mode, "mode": mode,
"source_provider": data_source.source_kind, "source_provider": source_policy.get("selected_source"),
"source_policy": source_policy, "source_policy": source_policy,
"primary_writer_result": primary_writer_result,
"page_size": page_size or get_historical_crcon_page_size(), "page_size": page_size or get_historical_crcon_page_size(),
"start_page": start_page, "start_page": start_page,
"detail_workers": detail_workers or get_historical_crcon_detail_workers(), "detail_workers": detail_workers or get_historical_crcon_detail_workers(),
@@ -257,6 +300,8 @@ def _ingest_server(
start_page: int, start_page: int,
detail_workers: int | None, detail_workers: int | None,
cutoff: str | None, cutoff: str | None,
progress_callback: ProgressCallback | None,
source_policy: dict[str, object],
) -> dict[str, object]: ) -> dict[str, object]:
resolved_page_size = page_size or get_historical_crcon_page_size() resolved_page_size = page_size or get_historical_crcon_page_size()
resolved_detail_workers = detail_workers or get_historical_crcon_detail_workers() resolved_detail_workers = detail_workers or get_historical_crcon_detail_workers()
@@ -267,6 +312,18 @@ def _ingest_server(
discovered_total_matches: int | None = None discovered_total_matches: int | None = None
last_page_processed: int | None = None last_page_processed: int | None = None
archive_exhausted = False archive_exhausted = False
_emit_progress(
progress_callback,
{
"event": "historical-ingestion-server-started",
"mode": mode,
"server_slug": server["slug"],
"selected_source": source_policy.get("selected_source"),
"fallback_used": bool(source_policy.get("fallback_used")),
"start_page": start_page,
"cutoff": cutoff,
},
)
for page_number in range(start_page, start_page + page_limit): for page_number in range(start_page, start_page + page_limit):
payload = data_source.fetch_match_page( payload = data_source.fetch_match_page(
@@ -300,6 +357,20 @@ def _ingest_server(
if match_id: if match_id:
match_ids_to_fetch.append(match_id) match_ids_to_fetch.append(match_id)
_emit_progress(
progress_callback,
{
"event": "historical-ingestion-page-loaded",
"mode": mode,
"server_slug": server["slug"],
"page": page_number,
"selected_source": source_policy.get("selected_source"),
"match_ids_to_detail": len(match_ids_to_fetch),
"page_matches": len(page_matches),
"cutoff_reached": stop_after_page,
},
)
for detail_payload in data_source.fetch_match_details( for detail_payload in data_source.fetch_match_details(
base_url=str(server["scoreboard_base_url"]), base_url=str(server["scoreboard_base_url"]),
match_ids=match_ids_to_fetch, match_ids=match_ids_to_fetch,
@@ -356,6 +427,162 @@ def _resolve_start_page(
return get_backfill_resume_page(server_slug, mode=mode) return get_backfill_resume_page(server_slug, mode=mode)
def _attempt_primary_rcon_writer(
*,
mode: str,
server_slug: str | None,
selected_servers: list[dict[str, object]],
progress_callback: ProgressCallback | None,
) -> dict[str, object]:
configured_kind = get_historical_data_source_kind()
if configured_kind != SOURCE_KIND_RCON:
result = {
"attempted": False,
"status": "skipped",
"primary_source": SOURCE_KIND_PUBLIC_SCOREBOARD,
"selected_source": SOURCE_KIND_PUBLIC_SCOREBOARD,
"fallback_used": False,
"fallback_reason": None,
"source_attempts": [],
}
_emit_progress(
progress_callback,
{
"event": "historical-ingestion-rcon-primary-skipped",
"mode": mode,
"reason": "historical-data-source-configured-for-public-scoreboard",
},
)
return result
target_scope = server_slug or "all-configured-rcon-targets"
_emit_progress(
progress_callback,
{
"event": "historical-ingestion-rcon-primary-started",
"mode": mode,
"target_scope": target_scope,
"servers": [str(server["slug"]) for server in selected_servers],
},
)
try:
capture_result = run_rcon_historical_capture_unlocked(target_key=server_slug)
except Exception as exc: # noqa: BLE001 - fallback remains explicit and controlled
result = {
"attempted": True,
"status": "error",
"primary_source": SOURCE_KIND_RCON,
"selected_source": SOURCE_KIND_PUBLIC_SCOREBOARD,
"fallback_used": True,
"fallback_reason": "rcon-historical-writer-request-failed",
"message": str(exc),
}
_emit_progress(
progress_callback,
{
"event": "historical-ingestion-rcon-primary-failed",
"mode": mode,
"target_scope": target_scope,
"message": str(exc),
},
)
return result
capture_run_status = str(capture_result.get("run_status") or capture_result.get("status") or "unknown")
targets = list(capture_result.get("targets") or [])
errors = list(capture_result.get("errors") or [])
if targets:
result = {
"attempted": True,
"status": "partial",
"primary_source": SOURCE_KIND_RCON,
"selected_source": SOURCE_KIND_PUBLIC_SCOREBOARD,
"fallback_used": True,
"fallback_reason": "rcon-primary-writer-succeeded-but-classic-match-archive-still-needs-fallback",
"capture_result": capture_result,
}
_emit_progress(
progress_callback,
{
"event": "historical-ingestion-rcon-primary-succeeded",
"mode": mode,
"target_scope": target_scope,
"captured_targets": len(targets),
"run_status": capture_run_status,
"next_step": "classic-public-scoreboard-fallback-required",
},
)
return result
result = {
"attempted": True,
"status": "empty",
"primary_source": SOURCE_KIND_RCON,
"selected_source": SOURCE_KIND_PUBLIC_SCOREBOARD,
"fallback_used": True,
"fallback_reason": "rcon-historical-writer-returned-no-usable-samples",
"capture_result": capture_result,
"message": json.dumps(errors, separators=(",", ":")) if errors else None,
}
_emit_progress(
progress_callback,
{
"event": "historical-ingestion-rcon-primary-empty",
"mode": mode,
"target_scope": target_scope,
"run_status": capture_run_status,
"errors": len(errors),
},
)
return result
def _should_use_classic_fallback(primary_writer_result: dict[str, object]) -> bool:
selected_source = str(primary_writer_result.get("selected_source") or "")
return selected_source == SOURCE_KIND_PUBLIC_SCOREBOARD
def _resolve_ingestion_source_policy(
*,
fallback_source_policy: dict[str, object],
primary_writer_result: dict[str, object],
) -> dict[str, object]:
configured_kind = get_historical_data_source_kind()
if configured_kind != SOURCE_KIND_RCON:
return fallback_source_policy
status = str(primary_writer_result.get("status") or "error")
selected_source = str(
primary_writer_result.get("selected_source") or SOURCE_KIND_PUBLIC_SCOREBOARD
)
fallback_reason = primary_writer_result.get("fallback_reason")
message = primary_writer_result.get("message")
if (
fallback_reason
== "rcon-primary-writer-succeeded-but-classic-match-archive-still-needs-fallback"
):
message = (
"RCON prospective capture succeeded first, but the classic historical_* "
"archive still requires public-scoreboard for match-page import."
)
return build_historical_runtime_source_policy(
operation="historical-ingestion",
rcon_status=status,
fallback_reason=str(fallback_reason) if fallback_reason else None,
selected_source=selected_source,
rcon_message=message if isinstance(message, str) else None,
)
def _emit_progress(
callback: ProgressCallback | None,
payload: dict[str, object],
) -> None:
if callback is None:
return
callback(payload)
def _select_servers(server_slug: str | None) -> list[dict[str, object]]: def _select_servers(server_slug: str | None) -> list[dict[str, object]]:
servers = list_historical_servers() servers = list_historical_servers()
if server_slug is None: if server_slug is None:
@@ -456,6 +683,9 @@ def main(argv: Iterable[str] | None = None) -> int:
parser = build_arg_parser() parser = build_arg_parser()
args = parser.parse_args(list(argv) if argv is not None else None) args = parser.parse_args(list(argv) if argv is not None else None)
def _print_progress(payload: dict[str, object]) -> None:
print(json.dumps(payload, ensure_ascii=True))
if args.mode == "bootstrap": if args.mode == "bootstrap":
result = run_bootstrap( result = run_bootstrap(
server_slug=args.server_slug, server_slug=args.server_slug,
@@ -463,6 +693,7 @@ def main(argv: Iterable[str] | None = None) -> int:
page_size=args.page_size, page_size=args.page_size,
start_page=args.start_page, start_page=args.start_page,
detail_workers=args.detail_workers, detail_workers=args.detail_workers,
progress_callback=_print_progress,
) )
else: else:
result = run_incremental_refresh( result = run_incremental_refresh(
@@ -472,6 +703,7 @@ def main(argv: Iterable[str] | None = None) -> int:
start_page=args.start_page, start_page=args.start_page,
detail_workers=args.detail_workers, detail_workers=args.detail_workers,
overlap_hours=args.overlap_hours, overlap_hours=args.overlap_hours,
progress_callback=_print_progress,
) )
print(json.dumps(result, indent=2)) print(json.dumps(result, indent=2))

View File

@@ -44,6 +44,14 @@ def run_rcon_historical_capture(
f"app.rcon_historical_worker capture:{target_key or 'all-targets'}" f"app.rcon_historical_worker capture:{target_key or 'all-targets'}"
) )
): ):
return run_rcon_historical_capture_unlocked(target_key=target_key)
def run_rcon_historical_capture_unlocked(
*,
target_key: str | None = None,
) -> dict[str, object]:
"""Capture one prospective RCON sample assuming the shared writer lock is already held."""
initialize_rcon_historical_storage() initialize_rcon_historical_storage()
selected_targets = _select_targets(target_key) selected_targets = _select_targets(target_key)
captured_at = utc_now().isoformat().replace("+00:00", "Z") captured_at = utc_now().isoformat().replace("+00:00", "Z")