Split live AdminLog ingestion from historical materialization
This commit is contained in:
@@ -17,6 +17,9 @@ COMPETITIVE_WINDOW_GAP_SECONDS = 1800
|
||||
COMPETITIVE_MODE_PARTIAL = "partial"
|
||||
COMPETITIVE_MODE_APPROXIMATE = "approximate"
|
||||
COMPETITIVE_MODE_EXACT = "exact"
|
||||
RUNNING_HISTORICAL_CAPTURE_CONFLICT_MESSAGE = (
|
||||
"historical materialization capture already running"
|
||||
)
|
||||
|
||||
|
||||
RCON_SCHEMA_SQL = """
|
||||
@@ -51,6 +54,10 @@ CREATE TABLE IF NOT EXISTS rcon_historical_capture_runs (
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_rcon_historical_single_running_historical
|
||||
ON rcon_historical_capture_runs(mode)
|
||||
WHERE status = 'running' AND mode = 'historical';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rcon_historical_samples (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
target_id BIGINT NOT NULL REFERENCES rcon_historical_targets(id),
|
||||
@@ -475,14 +482,27 @@ def connect_postgres_compat(*, initialize: bool = True):
|
||||
def start_capture_run(*, mode: str, target_scope: str) -> int:
|
||||
initialize_postgres_rcon_storage()
|
||||
with connect_postgres() as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
INSERT INTO rcon_historical_capture_runs (mode, status, target_scope, started_at)
|
||||
VALUES (%s, 'running', %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(mode, target_scope, _utc_now_iso()),
|
||||
).fetchone()
|
||||
try:
|
||||
row = connection.execute(
|
||||
"""
|
||||
INSERT INTO rcon_historical_capture_runs (mode, status, target_scope, started_at)
|
||||
VALUES (%s, 'running', %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(mode, target_scope, _utc_now_iso()),
|
||||
).fetchone()
|
||||
except Exception as error:
|
||||
try:
|
||||
import psycopg
|
||||
except ImportError:
|
||||
psycopg = None # type: ignore[assignment]
|
||||
if (
|
||||
mode == "historical"
|
||||
and psycopg is not None
|
||||
and isinstance(error, psycopg.errors.UniqueViolation)
|
||||
):
|
||||
raise RuntimeError(RUNNING_HISTORICAL_CAPTURE_CONFLICT_MESSAGE) from error
|
||||
raise
|
||||
return int(row["id"])
|
||||
|
||||
|
||||
|
||||
@@ -108,12 +108,21 @@ def persist_rcon_admin_log_entries(
|
||||
target: Mapping[str, object],
|
||||
entries: list[dict[str, object]],
|
||||
db_path: Path | None = None,
|
||||
ensure_storage: bool = True,
|
||||
) -> dict[str, int]:
|
||||
"""Persist raw and parsed AdminLog entries idempotently."""
|
||||
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||
return _persist_rcon_admin_log_entries_postgres(target=target, entries=entries)
|
||||
return _persist_rcon_admin_log_entries_postgres(
|
||||
target=target,
|
||||
entries=entries,
|
||||
ensure_storage=ensure_storage,
|
||||
)
|
||||
|
||||
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
|
||||
resolved_path = (
|
||||
initialize_rcon_admin_log_storage(db_path=db_path)
|
||||
if ensure_storage
|
||||
else (db_path or get_storage_path())
|
||||
)
|
||||
target_key = str(target.get("target_key") or target.get("external_server_id") or "")
|
||||
if not target_key:
|
||||
raise ValueError("target must include target_key or external_server_id")
|
||||
@@ -1165,6 +1174,7 @@ def _persist_rcon_admin_log_entries_postgres(
|
||||
*,
|
||||
target: Mapping[str, object],
|
||||
entries: list[dict[str, object]],
|
||||
ensure_storage: bool,
|
||||
) -> dict[str, int]:
|
||||
from .postgres_rcon_storage import connect_postgres_compat
|
||||
|
||||
@@ -1175,7 +1185,7 @@ def _persist_rcon_admin_log_entries_postgres(
|
||||
external_server_id = target.get("external_server_id")
|
||||
inserted = 0
|
||||
duplicates = 0
|
||||
with connect_postgres_compat() as connection:
|
||||
with connect_postgres_compat(initialize=ensure_storage) as connection:
|
||||
for entry in entries:
|
||||
parsed = parse_rcon_admin_log_entry(entry)
|
||||
raw_message = str(parsed.get("raw_message") or "")
|
||||
|
||||
@@ -13,13 +13,14 @@ from .config import (
|
||||
get_current_match_adminlog_enabled,
|
||||
get_current_match_adminlog_interval_seconds,
|
||||
get_current_match_adminlog_lookback_seconds,
|
||||
get_rcon_current_match_writer_lock_timeout_seconds,
|
||||
)
|
||||
from .rcon_admin_log_ingestion import fetch_recent_admin_log_entries, serialize_rcon_target
|
||||
from .rcon_admin_log_storage import persist_rcon_admin_log_entries
|
||||
from .rcon_admin_log_storage import (
|
||||
initialize_rcon_admin_log_storage,
|
||||
persist_rcon_admin_log_entries,
|
||||
)
|
||||
from .rcon_client import RconServerTarget, build_rcon_target_key, load_rcon_targets
|
||||
from .scoreboard_origins import list_trusted_public_scoreboard_origins
|
||||
from .writer_lock import backend_writer_lock, build_writer_lock_holder
|
||||
|
||||
|
||||
def list_current_match_trusted_targets() -> list[RconServerTarget]:
|
||||
@@ -41,19 +42,17 @@ def run_current_match_adminlog_refresh_once(
|
||||
fetch_entries_fn: Callable[..., list[dict[str, object]]] = fetch_recent_admin_log_entries,
|
||||
persist_entries_fn: Callable[..., dict[str, int]] = persist_rcon_admin_log_entries,
|
||||
db_path: object = None,
|
||||
ensure_storage: bool = True,
|
||||
) -> dict[str, object]:
|
||||
"""Refresh recent AdminLog rows once for trusted current-match targets."""
|
||||
with backend_writer_lock(
|
||||
holder=build_writer_lock_holder("app.rcon_current_match_worker once"),
|
||||
timeout_seconds=get_rcon_current_match_writer_lock_timeout_seconds(),
|
||||
):
|
||||
return run_current_match_adminlog_refresh_once_unlocked(
|
||||
lookback_seconds=lookback_seconds,
|
||||
targets=targets,
|
||||
fetch_entries_fn=fetch_entries_fn,
|
||||
persist_entries_fn=persist_entries_fn,
|
||||
db_path=db_path,
|
||||
)
|
||||
return run_current_match_adminlog_refresh_once_unlocked(
|
||||
lookback_seconds=lookback_seconds,
|
||||
targets=targets,
|
||||
fetch_entries_fn=fetch_entries_fn,
|
||||
persist_entries_fn=persist_entries_fn,
|
||||
db_path=db_path,
|
||||
ensure_storage=ensure_storage,
|
||||
)
|
||||
|
||||
|
||||
def run_current_match_adminlog_refresh_once_unlocked(
|
||||
@@ -63,12 +62,11 @@ def run_current_match_adminlog_refresh_once_unlocked(
|
||||
fetch_entries_fn: Callable[..., list[dict[str, object]]] = fetch_recent_admin_log_entries,
|
||||
persist_entries_fn: Callable[..., dict[str, int]] = persist_rcon_admin_log_entries,
|
||||
db_path: object = None,
|
||||
ensure_storage: bool = True,
|
||||
) -> dict[str, object]:
|
||||
"""Refresh recent AdminLog rows once assuming the shared writer lock is already held."""
|
||||
resolved_lookback_seconds = (
|
||||
get_current_match_adminlog_lookback_seconds()
|
||||
if lookback_seconds is None
|
||||
else int(lookback_seconds)
|
||||
"""Refresh recent AdminLog rows once using idempotent event inserts only."""
|
||||
resolved_lookback_seconds = _resolve_lookback_seconds(
|
||||
lookback_seconds=lookback_seconds,
|
||||
)
|
||||
if resolved_lookback_seconds <= 0:
|
||||
raise ValueError("lookback_seconds must be positive.")
|
||||
@@ -78,6 +76,8 @@ def run_current_match_adminlog_refresh_once_unlocked(
|
||||
if not selected_targets:
|
||||
raise RuntimeError("No trusted current-match RCON targets are configured.")
|
||||
|
||||
if ensure_storage:
|
||||
initialize_rcon_admin_log_storage(db_path=resolved_db_path)
|
||||
timeout_seconds = None
|
||||
refreshed_at = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
items: list[dict[str, object]] = []
|
||||
@@ -104,6 +104,7 @@ def run_current_match_adminlog_refresh_once_unlocked(
|
||||
target=target_metadata,
|
||||
entries=entries,
|
||||
db_path=resolved_db_path,
|
||||
ensure_storage=False,
|
||||
)
|
||||
duration_ms = round((time.perf_counter() - started) * 1000, 2)
|
||||
totals["entries_seen"] += int(delta.get("events_seen") or 0)
|
||||
@@ -147,6 +148,7 @@ def run_current_match_adminlog_refresh_loop(
|
||||
*,
|
||||
interval_seconds: int | None = None,
|
||||
lookback_seconds: int | None = None,
|
||||
lookback_minutes: int | None = None,
|
||||
max_runs: int | None = None,
|
||||
) -> None:
|
||||
"""Run the lightweight current-match AdminLog refresher in a loop."""
|
||||
@@ -165,10 +167,9 @@ def run_current_match_adminlog_refresh_loop(
|
||||
"current-match-adminlog-worker-started",
|
||||
enabled=get_current_match_adminlog_enabled(),
|
||||
interval_seconds=resolved_interval_seconds,
|
||||
lookback_seconds=(
|
||||
get_current_match_adminlog_lookback_seconds()
|
||||
if lookback_seconds is None
|
||||
else int(lookback_seconds)
|
||||
lookback_seconds=_resolve_lookback_seconds(
|
||||
lookback_seconds=lookback_seconds,
|
||||
lookback_minutes=lookback_minutes,
|
||||
),
|
||||
targets=[
|
||||
{
|
||||
@@ -179,11 +180,17 @@ def run_current_match_adminlog_refresh_loop(
|
||||
for target in list_current_match_trusted_targets()
|
||||
],
|
||||
)
|
||||
initialize_rcon_admin_log_storage()
|
||||
try:
|
||||
while max_runs is None or run_count < max_runs:
|
||||
run_count += 1
|
||||
_emit_worker_event("current-match-adminlog-cycle-started", run=run_count)
|
||||
result = run_current_match_adminlog_refresh_once(
|
||||
lookback_seconds=lookback_seconds,
|
||||
lookback_seconds=_resolve_lookback_seconds(
|
||||
lookback_seconds=lookback_seconds,
|
||||
lookback_minutes=lookback_minutes,
|
||||
),
|
||||
ensure_storage=False,
|
||||
)
|
||||
_emit_worker_event(
|
||||
"current-match-adminlog-cycle-finished",
|
||||
@@ -192,6 +199,11 @@ def run_current_match_adminlog_refresh_loop(
|
||||
)
|
||||
if max_runs is not None and run_count >= max_runs:
|
||||
break
|
||||
_emit_worker_event(
|
||||
"current-match-adminlog-sleep-started",
|
||||
run=run_count,
|
||||
sleep_seconds=resolved_interval_seconds,
|
||||
)
|
||||
time.sleep(resolved_interval_seconds)
|
||||
except KeyboardInterrupt:
|
||||
_emit_worker_event("current-match-adminlog-worker-stopped", reason="keyboard-interrupt")
|
||||
@@ -218,6 +230,11 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
||||
default=get_current_match_adminlog_lookback_seconds(),
|
||||
help="overlap-safe AdminLog lookback window in seconds",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lookback-minutes",
|
||||
type=int,
|
||||
help="optional overlap-safe AdminLog lookback window in minutes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-runs",
|
||||
type=int,
|
||||
@@ -230,10 +247,14 @@ def main(argv: Iterable[str] | None = None) -> int:
|
||||
parser = build_arg_parser()
|
||||
args = parser.parse_args(list(argv) if argv is not None else None)
|
||||
if args.mode == "once":
|
||||
lookback_seconds = _resolve_lookback_seconds(
|
||||
lookback_seconds=args.lookback_seconds,
|
||||
lookback_minutes=args.lookback_minutes,
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
run_current_match_adminlog_refresh_once(
|
||||
lookback_seconds=args.lookback_seconds,
|
||||
lookback_seconds=lookback_seconds,
|
||||
),
|
||||
indent=2,
|
||||
)
|
||||
@@ -243,11 +264,26 @@ def main(argv: Iterable[str] | None = None) -> int:
|
||||
run_current_match_adminlog_refresh_loop(
|
||||
interval_seconds=args.interval,
|
||||
lookback_seconds=args.lookback_seconds,
|
||||
lookback_minutes=args.lookback_minutes,
|
||||
max_runs=args.max_runs,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _resolve_lookback_seconds(
|
||||
*,
|
||||
lookback_seconds: int | None = None,
|
||||
lookback_minutes: int | None = None,
|
||||
) -> int:
|
||||
if lookback_minutes is not None:
|
||||
if int(lookback_minutes) <= 0:
|
||||
raise ValueError("lookback_minutes must be positive.")
|
||||
return int(lookback_minutes) * 60
|
||||
if lookback_seconds is None:
|
||||
return get_current_match_adminlog_lookback_seconds()
|
||||
return int(lookback_seconds)
|
||||
|
||||
|
||||
def _emit_worker_event(event: str, **fields: object) -> None:
|
||||
print(json.dumps({"event": event, **fields}, indent=2, default=str), flush=True)
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ COMPETITIVE_WINDOW_GAP_SECONDS = 1800
|
||||
COMPETITIVE_MODE_PARTIAL = "partial"
|
||||
COMPETITIVE_MODE_APPROXIMATE = "approximate"
|
||||
COMPETITIVE_MODE_EXACT = "exact"
|
||||
RUNNING_HISTORICAL_CAPTURE_CONFLICT_MESSAGE = (
|
||||
"historical materialization capture already running"
|
||||
)
|
||||
|
||||
|
||||
def initialize_rcon_historical_storage(
|
||||
@@ -72,6 +75,10 @@ def initialize_rcon_historical_storage(
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_rcon_historical_single_running_historical
|
||||
ON rcon_historical_capture_runs(mode)
|
||||
WHERE status = 'running' AND mode = 'historical';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rcon_historical_samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
target_id INTEGER NOT NULL,
|
||||
@@ -151,17 +158,22 @@ def start_rcon_historical_capture_run(
|
||||
|
||||
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
|
||||
with _connect(resolved_path) as connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO rcon_historical_capture_runs (
|
||||
mode,
|
||||
status,
|
||||
target_scope,
|
||||
started_at
|
||||
) VALUES (?, 'running', ?, ?)
|
||||
""",
|
||||
(mode, target_scope, _utc_now_iso()),
|
||||
)
|
||||
try:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO rcon_historical_capture_runs (
|
||||
mode,
|
||||
status,
|
||||
target_scope,
|
||||
started_at
|
||||
) VALUES (?, 'running', ?, ?)
|
||||
""",
|
||||
(mode, target_scope, _utc_now_iso()),
|
||||
)
|
||||
except sqlite3.IntegrityError as error:
|
||||
if mode == "historical":
|
||||
raise RuntimeError(RUNNING_HISTORICAL_CAPTURE_CONFLICT_MESSAGE) from error
|
||||
raise
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
|
||||
@@ -105,10 +105,47 @@ def run_rcon_historical_capture_unlocked(
|
||||
admin_log_lookback_minutes = get_rcon_admin_log_lookback_minutes()
|
||||
captured_at = utc_now().isoformat().replace("+00:00", "Z")
|
||||
target_scope = target_key or "all-configured-rcon-targets"
|
||||
run_id = start_rcon_historical_capture_run(
|
||||
mode=resolved_capture_mode,
|
||||
target_scope=target_scope,
|
||||
)
|
||||
try:
|
||||
run_id = start_rcon_historical_capture_run(
|
||||
mode=resolved_capture_mode,
|
||||
target_scope=target_scope,
|
||||
)
|
||||
except RuntimeError as error:
|
||||
if _is_historical_run_conflict(error, capture_mode=resolved_capture_mode):
|
||||
return {
|
||||
"status": "skipped",
|
||||
"run_status": "skipped",
|
||||
"captured_at": captured_at,
|
||||
"target_scope": target_scope,
|
||||
"capture_mode": resolved_capture_mode,
|
||||
"materialization_skipped": resolved_skip_materialization,
|
||||
"admin_log_lookback_minutes": admin_log_lookback_minutes,
|
||||
"admin_log_events_seen": 0,
|
||||
"admin_log_events_inserted": 0,
|
||||
"duplicate_events": 0,
|
||||
"samples_inserted": 0,
|
||||
"targets": [],
|
||||
"errors": [],
|
||||
"admin_log_errors": [],
|
||||
"materialization_result": {
|
||||
"status": "skipped",
|
||||
"reason": "already-running",
|
||||
},
|
||||
"storage_status": [],
|
||||
"totals": {
|
||||
"targets_seen": 0,
|
||||
"samples_inserted": 0,
|
||||
"duplicate_samples": 0,
|
||||
"failed_targets": 0,
|
||||
"admin_log_events_seen": 0,
|
||||
"admin_log_events_inserted": 0,
|
||||
"admin_log_duplicate_events": 0,
|
||||
"admin_log_failed_targets": 0,
|
||||
"materialized_matches_inserted": 0,
|
||||
"materialized_matches_updated": 0,
|
||||
},
|
||||
}
|
||||
raise
|
||||
stats = RconHistoricalCaptureStats()
|
||||
items: list[dict[str, object]] = []
|
||||
errors: list[dict[str, object]] = []
|
||||
@@ -355,6 +392,12 @@ def _run_capture_with_retries(
|
||||
time.sleep(retry_delay_seconds)
|
||||
|
||||
|
||||
def _is_historical_run_conflict(error: RuntimeError, *, capture_mode: str) -> bool:
|
||||
return capture_mode == CAPTURE_MODE_HISTORICAL and (
|
||||
"already running" in str(error).lower()
|
||||
)
|
||||
|
||||
|
||||
def _select_targets(target_key: str | None) -> list[object]:
|
||||
configured_targets = list(load_rcon_targets())
|
||||
if not configured_targets:
|
||||
|
||||
Reference in New Issue
Block a user