Files
comunidadhll/backend/app/data_sources.py
devRaGonSa 0cf98a1be9
Some checks failed
Codex Worker / run-codex-worker (push) Has been cancelled
initial export
2026-06-02 16:23:16 +02:00

447 lines
16 KiB
Python

"""Data source selection and contracts for live and historical backend flows."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
from .collector import collect_server_snapshots
from .config import get_historical_data_source_kind, get_live_data_source_kind
from .providers.public_scoreboard_provider import PublicScoreboardHistoricalDataSource
from .providers.rcon_provider import RconLiveDataSource
from .rcon_historical_read_model import (
describe_rcon_historical_read_model,
list_rcon_historical_recent_activity,
list_rcon_historical_server_summaries,
)
from .server_targets import A2SServerTarget, load_a2s_targets
LIVE_SOURCE_A2S = "a2s"
SOURCE_KIND_PUBLIC_SCOREBOARD = "public-scoreboard"
SOURCE_KIND_RCON = "rcon"
class HistoricalDataSource(Protocol):
"""Contract for historical providers used by ingestion flows."""
source_kind: str
def fetch_public_info(self, *, base_url: str) -> dict[str, object]:
"""Fetch provider metadata for one historical source."""
def fetch_match_page(self, *, base_url: str, page: int, limit: int) -> dict[str, object]:
"""Fetch one page of historical matches."""
def fetch_match_details(
self,
*,
base_url: str,
match_ids: list[str],
max_workers: int,
) -> list[dict[str, object]]:
"""Fetch detailed payloads for one batch of matches."""
class LiveDataSource(Protocol):
"""Contract for live providers used by API payload builders."""
source_kind: str
def collect_snapshots(self, *, persist: bool) -> dict[str, object]:
"""Collect one live snapshot batch."""
def build_target_index(self) -> dict[str | None, object]:
"""Return optional server connection metadata keyed by external id."""
@dataclass(frozen=True, slots=True)
class A2SLiveDataSource:
"""Live provider backed by the existing A2S collector flow."""
source_kind: str = LIVE_SOURCE_A2S
def collect_snapshots(self, *, persist: bool) -> dict[str, object]:
return collect_server_snapshots(
source_mode="a2s",
allow_controlled_fallback=False,
persist=persist,
)
def build_target_index(self) -> dict[str | None, A2SServerTarget]:
return {
target.external_server_id: target
for target in load_a2s_targets()
if target.external_server_id
}
@dataclass(frozen=True, slots=True)
class RconFirstLiveDataSource:
"""Live source arbitration with RCON as primary and A2S as controlled fallback."""
primary_source: RconLiveDataSource = RconLiveDataSource()
fallback_source: A2SLiveDataSource = A2SLiveDataSource()
source_kind: str = SOURCE_KIND_RCON
def collect_snapshots(self, *, persist: bool) -> dict[str, object]:
attempts: list[dict[str, object]] = []
fallback_reason: str | None = None
try:
primary_payload = self.primary_source.collect_snapshots(persist=persist)
except Exception as error: # noqa: BLE001 - source arbitration keeps fallback controlled
attempts.append(
build_source_attempt(
source=SOURCE_KIND_RCON,
role="primary",
status="error",
reason="rcon-live-request-failed",
message=str(error),
)
)
fallback_reason = "rcon-live-request-failed"
else:
primary_success_count = int(primary_payload.get("success_count") or 0)
primary_snapshots = list(primary_payload.get("snapshots") or [])
if primary_success_count > 0 and primary_snapshots:
attempts.append(
build_source_attempt(
source=SOURCE_KIND_RCON,
role="primary",
status="success",
)
)
return attach_source_policy(
primary_payload,
build_source_policy(
primary_source=SOURCE_KIND_RCON,
selected_source=SOURCE_KIND_RCON,
source_attempts=attempts,
),
)
attempts.append(
build_source_attempt(
source=SOURCE_KIND_RCON,
role="primary",
status="empty",
reason="rcon-live-returned-no-usable-snapshots",
message=f"success_count={primary_success_count}",
)
)
fallback_reason = "rcon-live-returned-no-usable-snapshots"
try:
fallback_payload = self.fallback_source.collect_snapshots(persist=persist)
except Exception as error: # noqa: BLE001 - keep combined failure explicit
attempts.append(
build_source_attempt(
source=LIVE_SOURCE_A2S,
role="fallback",
status="error",
reason="a2s-live-fallback-failed",
message=str(error),
)
)
raise RuntimeError(
"RCON-first live collection failed and A2S fallback also failed."
) from error
attempts.append(
build_source_attempt(
source=LIVE_SOURCE_A2S,
role="fallback",
status="success",
)
)
return attach_source_policy(
fallback_payload,
build_source_policy(
primary_source=SOURCE_KIND_RCON,
selected_source=LIVE_SOURCE_A2S,
fallback_used=True,
fallback_reason=fallback_reason,
source_attempts=attempts,
),
)
def build_target_index(self) -> dict[str | None, object]:
target_index = dict(self.fallback_source.build_target_index())
target_index.update(self.primary_source.build_target_index())
return target_index
@dataclass(frozen=True, slots=True)
class RconHistoricalDataSource:
"""Persisted RCON-backed historical read model over captured competitive windows."""
source_kind: str = SOURCE_KIND_RCON
def fetch_public_info(self, *, base_url: str) -> dict[str, object]:
raise RuntimeError(
"RCON historical read mode does not support CRCON ingestion operations."
)
def fetch_match_page(self, *, base_url: str, page: int, limit: int) -> dict[str, object]:
raise RuntimeError(
"RCON historical read mode does not support CRCON ingestion operations."
)
def fetch_match_details(
self,
*,
base_url: str,
match_ids: list[str],
max_workers: int,
) -> list[dict[str, object]]:
raise RuntimeError(
"RCON historical read mode does not support CRCON ingestion operations."
)
def list_server_summaries(self, *, server_key: str | None = None) -> list[dict[str, object]]:
"""Return coverage and freshness from persisted RCON-backed competitive history."""
return list_rcon_historical_server_summaries(server_key=server_key)
def list_recent_activity(
self,
*,
server_key: str | None = None,
limit: int = 20,
) -> list[dict[str, object]]:
"""Return recent RCON-backed competitive history without on-demand network calls."""
return list_rcon_historical_recent_activity(server_key=server_key, limit=limit)
def has_server_summary_coverage(self, items: list[dict[str, object]]) -> bool:
"""Return whether RCON summaries contain usable historical coverage."""
for item in items:
coverage = item.get("coverage") if isinstance(item, dict) else None
if not isinstance(coverage, dict):
continue
if coverage.get("status") == "available":
return True
if int(coverage.get("sample_count") or 0) > 0:
return True
if int(coverage.get("window_count") or 0) > 0:
return True
if coverage.get("last_sample_at"):
return True
return False
def has_recent_activity_coverage(self, items: list[dict[str, object]]) -> bool:
"""Return whether RCON recent activity contains at least one usable item."""
for item in items:
if not isinstance(item, dict):
continue
if item.get("closed_at") or item.get("ended_at") or item.get("started_at"):
return True
if int(item.get("sample_count") or 0) > 0:
return True
return False
def describe_capabilities(self) -> dict[str, object]:
"""Describe the supported RCON historical read surface."""
return describe_rcon_historical_read_model()
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 == SOURCE_KIND_PUBLIC_SCOREBOARD:
return PublicScoreboardHistoricalDataSource()
if source_kind == SOURCE_KIND_RCON:
return RconHistoricalDataSource()
raise ValueError(f"Unsupported historical data source: {source_kind}")
def get_live_data_source() -> LiveDataSource:
"""Select the live provider configured for the current environment."""
source_kind = get_live_data_source_kind()
if source_kind == LIVE_SOURCE_A2S:
return A2SLiveDataSource()
if source_kind == SOURCE_KIND_RCON:
return RconFirstLiveDataSource()
raise ValueError(f"Unsupported live data source: {source_kind}")
def get_rcon_historical_read_model() -> RconHistoricalDataSource | None:
"""Return the persisted RCON-backed historical read model when selected."""
if get_historical_data_source_kind() != SOURCE_KIND_RCON:
return None
return RconHistoricalDataSource()
def describe_historical_runtime_policy() -> dict[str, object]:
"""Describe the effective historical runtime policy for the current environment."""
if get_historical_data_source_kind() != SOURCE_KIND_RCON:
return {
"mode": "public-scoreboard-primary",
"primary_source": SOURCE_KIND_PUBLIC_SCOREBOARD,
"fallback_source": None,
"summary": "Historical runtime uses public-scoreboard directly.",
}
return {
"mode": "rcon-first-with-public-scoreboard-fallback",
"primary_source": SOURCE_KIND_RCON,
"fallback_source": SOURCE_KIND_PUBLIC_SCOREBOARD,
"summary": (
"Historical runtime attempts the persisted RCON-backed competitive model first "
"and falls back to public-scoreboard when the requested operation is unsupported, has "
"no coverage yet, or the primary path fails."
),
}
def build_historical_runtime_source_policy(
*,
operation: str,
rcon_status: str,
fallback_reason: str | None = None,
selected_source: str | None = None,
rcon_message: str | None = None,
) -> dict[str, object]:
"""Build one normalized source-policy block for historical runtime reads."""
configured_kind = get_historical_data_source_kind()
if configured_kind != SOURCE_KIND_RCON:
return build_source_policy(
primary_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
source_attempts=[
build_source_attempt(
source=SOURCE_KIND_PUBLIC_SCOREBOARD,
role="primary",
status="success",
reason=f"{operation}-served-by-public-scoreboard",
)
],
)
if rcon_status == "success":
return build_source_policy(
primary_source=SOURCE_KIND_RCON,
selected_source=selected_source or SOURCE_KIND_RCON,
source_attempts=[
build_source_attempt(
source=SOURCE_KIND_RCON,
role="primary",
status="success",
reason=f"{operation}-served-by-rcon",
)
],
)
return build_source_policy(
primary_source=SOURCE_KIND_RCON,
selected_source=selected_source or SOURCE_KIND_PUBLIC_SCOREBOARD,
fallback_used=True,
fallback_reason=fallback_reason,
source_attempts=[
build_source_attempt(
source=SOURCE_KIND_RCON,
role="primary",
status=rcon_status,
reason=fallback_reason,
message=rcon_message,
),
build_source_attempt(
source=SOURCE_KIND_PUBLIC_SCOREBOARD,
role="fallback",
status="success",
reason=f"{operation}-served-by-public-scoreboard-fallback",
),
],
)
def resolve_historical_ingestion_data_source() -> tuple[HistoricalDataSource, dict[str, object]]:
"""Resolve the fallback provider used when classic scoreboard import is required."""
configured_kind = get_historical_data_source_kind()
if configured_kind in {SOURCE_KIND_PUBLIC_SCOREBOARD, SOURCE_KIND_RCON}:
primary_source = (
SOURCE_KIND_PUBLIC_SCOREBOARD
if configured_kind == SOURCE_KIND_PUBLIC_SCOREBOARD
else SOURCE_KIND_RCON
)
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:
attempts.append(
build_source_attempt(
source=SOURCE_KIND_RCON,
role="primary",
status="deferred",
reason="rcon-primary-writer-attempt-is-handled-by-historical-ingestion",
)
)
attempts.append(
build_source_attempt(
source=SOURCE_KIND_PUBLIC_SCOREBOARD,
role="fallback" if fallback_used else "primary",
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,
),
)
raise ValueError(f"Unsupported historical data source: {configured_kind}")
def build_source_attempt(
*,
source: str,
role: str,
status: str,
reason: str | None = None,
message: str | None = None,
) -> dict[str, object]:
"""Build one normalized trace entry for source arbitration."""
return {
"source": source,
"role": role,
"status": status,
"reason": reason,
"message": message,
}
def build_source_policy(
*,
primary_source: str,
selected_source: str,
fallback_used: bool = False,
fallback_reason: str | None = None,
source_attempts: list[dict[str, object]] | None = None,
) -> dict[str, object]:
"""Build one small source-policy block for API responses and worker output."""
return {
"primary_source": primary_source,
"selected_source": selected_source,
"fallback_used": fallback_used,
"fallback_reason": fallback_reason,
"source_attempts": list(source_attempts or []),
}
def attach_source_policy(
payload: dict[str, object],
source_policy: dict[str, object],
) -> dict[str, object]:
"""Attach normalized source-policy metadata to an existing payload."""
enriched = dict(payload)
enriched.update(source_policy)
return enriched