447 lines
16 KiB
Python
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
|