This commit is contained in:
devRaGonSa
2026-06-05 16:57:25 +02:00
commit 0da8338ba8
310 changed files with 45849 additions and 0 deletions

View File

@@ -0,0 +1,440 @@
"""Raw storage and run tracking for the V2 player event pipeline."""
from __future__ import annotations
import sqlite3
from collections.abc import Iterable
from datetime import datetime, timedelta, timezone
from pathlib import Path
from .config import (
get_player_event_refresh_overlap_hours,
get_storage_path,
use_postgres_rcon_storage,
)
from .player_event_models import PlayerEventRecord
from .sqlite_utils import connect_sqlite_writer
def initialize_player_event_storage(*, db_path: Path | None = None) -> Path:
"""Create the append-only player event ledger and its worker metadata tables."""
resolved_path = db_path or get_storage_path()
resolved_path.parent.mkdir(parents=True, exist_ok=True)
with _connect(resolved_path) as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS player_event_raw_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL UNIQUE,
event_type TEXT NOT NULL,
occurred_at TEXT,
server_slug TEXT NOT NULL,
external_match_id TEXT NOT NULL,
source_kind TEXT NOT NULL,
source_ref TEXT,
raw_event_ref TEXT,
killer_player_key TEXT,
killer_display_name TEXT,
victim_player_key TEXT,
victim_display_name TEXT,
weapon_name TEXT,
weapon_category TEXT,
kill_category TEXT,
is_teamkill INTEGER NOT NULL DEFAULT 0,
event_value INTEGER NOT NULL DEFAULT 1,
inserted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS player_event_ingestion_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mode TEXT NOT NULL,
status TEXT NOT NULL,
target_server_slug TEXT,
started_at TEXT NOT NULL,
completed_at TEXT,
pages_processed INTEGER NOT NULL DEFAULT 0,
matches_seen INTEGER NOT NULL DEFAULT 0,
matches_fetched INTEGER NOT NULL DEFAULT 0,
events_inserted INTEGER NOT NULL DEFAULT 0,
duplicate_events INTEGER NOT NULL DEFAULT 0,
notes TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS player_event_backfill_progress (
server_slug TEXT NOT NULL,
mode TEXT NOT NULL,
next_page INTEGER NOT NULL DEFAULT 1,
last_completed_page INTEGER,
cutoff_occurred_at TEXT,
discovered_total_matches INTEGER,
archive_exhausted INTEGER NOT NULL DEFAULT 0,
last_run_id INTEGER,
last_run_status TEXT,
last_run_started_at TEXT,
last_run_completed_at TEXT,
last_error TEXT,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (server_slug, mode)
);
CREATE INDEX IF NOT EXISTS idx_player_event_raw_server_match
ON player_event_raw_ledger(server_slug, external_match_id);
CREATE INDEX IF NOT EXISTS idx_player_event_raw_occurred_at
ON player_event_raw_ledger(occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_player_event_raw_killer_victim
ON player_event_raw_ledger(killer_player_key, victim_player_key);
"""
)
return resolved_path
def upsert_player_events(
events: Iterable[PlayerEventRecord],
*,
db_path: Path | None = None,
) -> dict[str, int]:
"""Insert normalized events idempotently into the raw ledger."""
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_display_storage import upsert_player_event_rows
return upsert_player_event_rows(events)
resolved_path = initialize_player_event_storage(db_path=db_path)
inserted = 0
duplicates = 0
with _connect(resolved_path) as connection:
for event in events:
cursor = connection.execute(
"""
INSERT OR IGNORE INTO player_event_raw_ledger (
event_id,
event_type,
occurred_at,
server_slug,
external_match_id,
source_kind,
source_ref,
raw_event_ref,
killer_player_key,
killer_display_name,
victim_player_key,
victim_display_name,
weapon_name,
weapon_category,
kill_category,
is_teamkill,
event_value
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
event.event_id,
event.event_type,
event.occurred_at,
event.server_slug,
event.external_match_id,
event.source_kind,
event.source_ref,
event.raw_event_ref,
event.killer_player_key,
event.killer_display_name,
event.victim_player_key,
event.victim_display_name,
event.weapon_name,
event.weapon_category,
event.kill_category,
1 if event.is_teamkill else 0,
max(1, int(event.event_value)),
),
)
if int(cursor.rowcount or 0) > 0:
inserted += 1
else:
duplicates += 1
return {
"events_inserted": inserted,
"duplicate_events": duplicates,
}
def start_player_event_ingestion_run(
*,
mode: str,
target_server_slug: str | None = None,
db_path: Path | None = None,
) -> int:
"""Persist one player event ingestion attempt."""
resolved_path = initialize_player_event_storage(db_path=db_path)
with _connect(resolved_path) as connection:
cursor = connection.execute(
"""
INSERT INTO player_event_ingestion_runs (
mode,
status,
target_server_slug,
started_at
) VALUES (?, 'running', ?, ?)
""",
(mode, target_server_slug, _utc_now_iso()),
)
return int(cursor.lastrowid)
def finalize_player_event_ingestion_run(
run_id: int,
*,
status: str,
pages_processed: int,
matches_seen: int,
matches_fetched: int,
events_inserted: int,
duplicate_events: int,
notes: str | None = None,
db_path: Path | None = None,
) -> None:
"""Update one player event ingestion attempt with final counters."""
resolved_path = initialize_player_event_storage(db_path=db_path)
with _connect(resolved_path) as connection:
connection.execute(
"""
UPDATE player_event_ingestion_runs
SET status = ?,
completed_at = ?,
pages_processed = ?,
matches_seen = ?,
matches_fetched = ?,
events_inserted = ?,
duplicate_events = ?,
notes = ?
WHERE id = ?
""",
(
status,
_utc_now_iso(),
pages_processed,
matches_seen,
matches_fetched,
events_inserted,
duplicate_events,
notes,
run_id,
),
)
def mark_player_event_progress_started(
*,
server_slug: str,
mode: str,
run_id: int,
cutoff_occurred_at: str | None,
db_path: Path | None = None,
) -> None:
"""Persist the start state for one server ingestion attempt."""
resolved_path = initialize_player_event_storage(db_path=db_path)
with _connect(resolved_path) as connection:
connection.execute(
"""
INSERT INTO player_event_backfill_progress (
server_slug,
mode,
next_page,
cutoff_occurred_at,
archive_exhausted,
last_run_id,
last_run_status,
last_run_started_at,
last_run_completed_at,
last_error
) VALUES (?, ?, 1, ?, 0, ?, 'running', ?, NULL, NULL)
ON CONFLICT(server_slug, mode) DO UPDATE SET
cutoff_occurred_at = excluded.cutoff_occurred_at,
last_run_id = excluded.last_run_id,
last_run_status = excluded.last_run_status,
last_run_started_at = excluded.last_run_started_at,
last_run_completed_at = NULL,
last_error = NULL,
updated_at = CURRENT_TIMESTAMP
""",
(server_slug, mode, cutoff_occurred_at, run_id, _utc_now_iso()),
)
def mark_player_event_progress_page_completed(
*,
server_slug: str,
mode: str,
page_number: int,
discovered_total_matches: int | None,
run_id: int,
db_path: Path | None = None,
) -> None:
"""Advance the resume checkpoint after one page completes successfully."""
resolved_path = initialize_player_event_storage(db_path=db_path)
with _connect(resolved_path) as connection:
connection.execute(
"""
INSERT INTO player_event_backfill_progress (
server_slug,
mode,
next_page,
last_completed_page,
discovered_total_matches,
archive_exhausted,
last_run_id,
last_run_status,
last_run_started_at,
last_run_completed_at,
last_error
) VALUES (?, ?, ?, ?, ?, 0, ?, 'running', ?, NULL, NULL)
ON CONFLICT(server_slug, mode) DO UPDATE SET
next_page = excluded.next_page,
last_completed_page = excluded.last_completed_page,
discovered_total_matches = COALESCE(
excluded.discovered_total_matches,
player_event_backfill_progress.discovered_total_matches
),
archive_exhausted = 0,
last_run_id = excluded.last_run_id,
last_run_status = excluded.last_run_status,
last_run_started_at = excluded.last_run_started_at,
last_run_completed_at = NULL,
last_error = NULL,
updated_at = CURRENT_TIMESTAMP
""",
(
server_slug,
mode,
page_number + 1,
page_number,
discovered_total_matches,
run_id,
_utc_now_iso(),
),
)
def finalize_player_event_progress(
*,
server_slug: str,
mode: str,
run_id: int,
status: str,
archive_exhausted: bool = False,
error_message: str | None = None,
db_path: Path | None = None,
) -> None:
"""Persist the final state of one server event ingestion attempt."""
resolved_path = initialize_player_event_storage(db_path=db_path)
with _connect(resolved_path) as connection:
connection.execute(
"""
INSERT INTO player_event_backfill_progress (
server_slug,
mode,
next_page,
archive_exhausted,
last_run_id,
last_run_status,
last_run_started_at,
last_run_completed_at,
last_error
) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?)
ON CONFLICT(server_slug, mode) DO UPDATE SET
archive_exhausted = CASE
WHEN excluded.last_run_status = 'success' AND excluded.archive_exhausted = 1
THEN 1
ELSE player_event_backfill_progress.archive_exhausted
END,
last_run_id = excluded.last_run_id,
last_run_status = excluded.last_run_status,
last_run_started_at = COALESCE(
player_event_backfill_progress.last_run_started_at,
excluded.last_run_started_at
),
last_run_completed_at = excluded.last_run_completed_at,
last_error = excluded.last_error,
updated_at = CURRENT_TIMESTAMP
""",
(
server_slug,
mode,
1 if archive_exhausted else 0,
run_id,
status,
_utc_now_iso(),
_utc_now_iso(),
error_message,
),
)
def get_player_event_resume_page(
server_slug: str,
*,
mode: str = "bootstrap",
db_path: Path | None = None,
) -> int:
"""Return the saved resume page for a bootstrap-like event backfill."""
resolved_path = initialize_player_event_storage(db_path=db_path)
with _connect(resolved_path) as connection:
row = connection.execute(
"""
SELECT next_page
FROM player_event_backfill_progress
WHERE server_slug = ? AND mode = ?
""",
(server_slug, mode),
).fetchone()
return max(1, int(row["next_page"])) if row and row["next_page"] else 1
def get_player_event_refresh_cutoff_for_server(
server_slug: str,
*,
overlap_hours: int | None = None,
db_path: Path | None = None,
) -> str | None:
"""Return the latest occurred_at already persisted for one server."""
resolved_overlap_hours = (
get_player_event_refresh_overlap_hours()
if overlap_hours is None
else overlap_hours
)
if resolved_overlap_hours < 0:
raise ValueError("overlap_hours must be zero or positive.")
resolved_path = initialize_player_event_storage(db_path=db_path)
with _connect(resolved_path) as connection:
row = connection.execute(
"""
SELECT MAX(occurred_at) AS latest_occurred_at
FROM player_event_raw_ledger
WHERE server_slug = ?
""",
(server_slug,),
).fetchone()
latest_occurred_at = str(row["latest_occurred_at"]) if row and row["latest_occurred_at"] else None
if not latest_occurred_at:
return None
cutoff = _parse_timestamp(latest_occurred_at) - timedelta(hours=resolved_overlap_hours)
return cutoff.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def _connect(db_path: Path) -> sqlite3.Connection:
return connect_sqlite_writer(db_path)
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _parse_timestamp(value: str) -> datetime:
normalized = value.strip().replace("Z", "+00:00")
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed