Add player search read model

This commit is contained in:
devRaGonSa
2026-06-09 10:31:21 +02:00
parent 3834662b23
commit 73a6c1afe1
6 changed files with 1144 additions and 3 deletions

View File

@@ -2,17 +2,115 @@
from __future__ import annotations
import argparse
import json
from contextlib import closing
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from pathlib import Path
import unicodedata
from .config import use_postgres_rcon_storage
from .historical_storage import ALL_SERVERS_SLUG
from .rcon_admin_log_materialization import MATCH_RESULT_SOURCE, initialize_rcon_materialized_storage
from .rcon_historical_leaderboards import select_leaderboard_window
from .sqlite_utils import connect_sqlite_readonly
from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer
from .rcon_historical_leaderboards import _to_iso
PLAYER_SEARCH_INDEX_SERVER_KEYS = (
ALL_SERVERS_SLUG,
"comunidad-hispana-01",
"comunidad-hispana-02",
)
PLAYER_SEARCH_INDEX_SQLITE_SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS player_search_index (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id TEXT NOT NULL,
player_id TEXT NOT NULL,
player_name TEXT NOT NULL,
normalized_player_name TEXT NOT NULL,
first_seen_at TEXT,
last_seen_at TEXT,
servers_seen TEXT NOT NULL DEFAULT '[]',
matches_current_year INTEGER NOT NULL DEFAULT 0,
kills_current_year INTEGER NOT NULL DEFAULT 0,
deaths_current_year INTEGER NOT NULL DEFAULT 0,
teamkills_current_year INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(server_id, player_id)
);
CREATE INDEX IF NOT EXISTS idx_player_search_index_name
ON player_search_index(server_id, normalized_player_name);
CREATE INDEX IF NOT EXISTS idx_player_search_index_last_seen
ON player_search_index(server_id, last_seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_player_search_index_player
ON player_search_index(server_id, player_id);
"""
def initialize_player_search_index_storage(*, db_path: Path | None = None) -> Path:
"""Create the player search read model storage if it does not exist."""
resolved_path = initialize_rcon_materialized_storage(db_path=db_path)
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import initialize_postgres_rcon_storage
initialize_postgres_rcon_storage()
return resolved_path
with closing(connect_sqlite_writer(resolved_path)) as connection:
with connection:
connection.executescript(PLAYER_SEARCH_INDEX_SQLITE_SCHEMA_SQL)
return resolved_path
def refresh_player_search_index(
*,
db_path: Path | None = None,
now: datetime | None = None,
) -> dict[str, object]:
"""Rebuild the player search read model from current-year materialized matches."""
anchor = _as_utc(now or datetime.now(timezone.utc))
year_start = anchor.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
next_year_start = year_start.replace(year=year_start.year + 1)
resolved_path = initialize_player_search_index_storage(db_path=db_path)
results: list[dict[str, object]] = []
total_rows = 0
with _connect_write_scope(resolved_path, db_path=db_path) as connection:
for server_id in PLAYER_SEARCH_INDEX_SERVER_KEYS:
rows = _build_player_search_index_rows(
connection=connection,
server_id=server_id,
year_start=year_start,
next_year_start=next_year_start,
)
_replace_player_search_index_scope(
connection=connection,
server_id=server_id,
rows=rows,
updated_at=_to_iso(anchor),
)
results.append(
{
"server_id": server_id,
"row_count": len(rows),
}
)
total_rows += len(rows)
return {
"status": "ok",
"generated_at": _to_iso(anchor),
"year": year_start.year,
"source": "rcon-materialized-admin-log",
"server_ids": list(PLAYER_SEARCH_INDEX_SERVER_KEYS),
"total_rows": total_rows,
"results": results,
}
def search_rcon_materialized_players(
*,
@@ -21,11 +119,43 @@ def search_rcon_materialized_players(
limit: int = 10,
db_path: Path | None = None,
) -> dict[str, object]:
"""Search players in materialized RCON/AdminLog statistics."""
"""Search players from the read model first, with runtime fallback."""
normalized_query = query.strip()
if not normalized_query:
raise ValueError("Query cannot be empty.")
read_model_result, fallback_reason = _search_player_search_index(
query=normalized_query,
server_id=server_id,
limit=limit,
db_path=db_path,
)
if read_model_result is not None:
return read_model_result
runtime_result = _search_rcon_materialized_players_runtime(
query=normalized_query,
server_id=server_id,
limit=limit,
db_path=db_path,
)
runtime_result["source"] = {
"read_model": "rcon-materialized-admin-log-player-stats",
"fallback_used": True,
"fallback_reason": fallback_reason or "player-search-index-unavailable",
}
return runtime_result
def _search_rcon_materialized_players_runtime(
*,
query: str,
server_id: str | None = None,
limit: int = 10,
db_path: Path | None = None,
) -> dict[str, object]:
"""Search players directly in materialized RCON/AdminLog statistics."""
normalized_query = query.strip()
resolved_path = initialize_rcon_materialized_storage(db_path=db_path)
resolved_server_id = _normalize_server_id(server_id)
normalized_pattern = _escape_like_pattern(normalized_query)
@@ -98,6 +228,11 @@ def search_rcon_materialized_players(
"server_id": resolved_server_id,
"query": normalized_query,
"items": items,
"source": {
"read_model": "rcon-materialized-admin-log-player-stats",
"fallback_used": False,
"fallback_reason": None,
},
}
@@ -386,6 +521,247 @@ def _to_datetime_or_none(value: object) -> datetime | None:
return parsed.astimezone(timezone.utc)
def _build_player_search_index_rows(
*,
connection: object,
server_id: str,
year_start: datetime,
next_year_start: datetime,
) -> list[dict[str, object]]:
scope_sql, scope_params = _build_scope_sql(server_id)
rows = connection.execute(
f"""
SELECT
stats.player_id,
MIN(COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT))) AS first_seen_at,
MAX(COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT))) AS last_seen_at,
COUNT(DISTINCT stats.match_key) AS matches_current_year,
SUM(COALESCE(stats.kills, 0)) AS kills_current_year,
SUM(COALESCE(stats.deaths, 0)) AS deaths_current_year,
SUM(COALESCE(stats.teamkills, 0)) AS teamkills_current_year
FROM rcon_match_player_stats AS stats
INNER JOIN rcon_materialized_matches AS matches
ON matches.target_key = stats.target_key
AND matches.match_key = stats.match_key
WHERE matches.source_basis = ?
AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) >= ?
AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) < ?
{scope_sql}
GROUP BY stats.player_id
HAVING COUNT(DISTINCT stats.match_key) > 0
ORDER BY MAX(COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT))) DESC,
COUNT(DISTINCT stats.match_key) DESC,
stats.player_id ASC
""",
[MATCH_RESULT_SOURCE, _to_iso(year_start), _to_iso(next_year_start), *scope_params],
).fetchall()
if not rows:
return []
player_ids = [
str(row["player_id"]).strip()
for row in rows
if str(row["player_id"] or "").strip()
]
latest_names = _lookup_latest_player_names_with_connection(
connection=connection,
player_ids=player_ids,
server_id=server_id,
start=year_start,
end=next_year_start,
)
servers_seen = _lookup_servers_seen_with_connection(
connection=connection,
player_ids=player_ids,
server_id=server_id,
start=year_start,
end=next_year_start,
)
index_rows: list[dict[str, object]] = []
for row in rows:
player_id = str(row["player_id"] or "").strip()
if not player_id:
continue
player_name = latest_names.get(player_id) or player_id
normalized_player_name = _normalize_player_search_text(player_name) or player_id.casefold()
index_rows.append(
{
"player_id": player_id,
"player_name": player_name,
"normalized_player_name": normalized_player_name,
"first_seen_at": row["first_seen_at"],
"last_seen_at": row["last_seen_at"],
"servers_seen": servers_seen.get(player_id, []),
"matches_current_year": int(row["matches_current_year"] or 0),
"kills_current_year": int(row["kills_current_year"] or 0),
"deaths_current_year": int(row["deaths_current_year"] or 0),
"teamkills_current_year": int(row["teamkills_current_year"] or 0),
}
)
return index_rows
def _replace_player_search_index_scope(
*,
connection: object,
server_id: str,
rows: list[dict[str, object]],
updated_at: str,
) -> None:
connection.execute(
"""
DELETE FROM player_search_index
WHERE server_id = ?
""",
[server_id],
)
for row in rows:
connection.execute(
"""
INSERT INTO player_search_index (
server_id,
player_id,
player_name,
normalized_player_name,
first_seen_at,
last_seen_at,
servers_seen,
matches_current_year,
kills_current_year,
deaths_current_year,
teamkills_current_year,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
server_id,
row["player_id"],
row["player_name"],
row["normalized_player_name"],
row["first_seen_at"],
row["last_seen_at"],
json.dumps(row["servers_seen"], ensure_ascii=True, separators=(",", ":")),
row["matches_current_year"],
row["kills_current_year"],
row["deaths_current_year"],
row["teamkills_current_year"],
updated_at,
],
)
def _search_player_search_index(
*,
query: str,
server_id: str | None = None,
limit: int = 10,
db_path: Path | None = None,
) -> tuple[dict[str, object] | None, str | None]:
resolved_path = initialize_player_search_index_storage(db_path=db_path)
resolved_server_id = _normalize_server_id(server_id)
normalized_query = query.strip()
normalized_name_query = _normalize_player_search_text(normalized_query)
anchor = datetime.now(timezone.utc)
year_start = anchor.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
escaped_name_contains = f"%{_escape_like_pattern(normalized_name_query)}%"
escaped_name_prefix = f"{_escape_like_pattern(normalized_name_query)}%"
escaped_id_contains = f"%{_escape_like_pattern(normalized_query.casefold())}%"
escaped_id_exact = normalized_query.casefold()
try:
with _connect_scope(resolved_path, db_path=db_path) as connection:
has_rows = connection.execute(
"""
SELECT 1
FROM player_search_index
WHERE server_id = ?
LIMIT 1
""",
[resolved_server_id],
).fetchone()
if has_rows is None:
return None, "player-search-index-empty"
rows = connection.execute(
"""
SELECT
player_id,
player_name,
first_seen_at,
last_seen_at,
servers_seen,
matches_current_year,
kills_current_year,
deaths_current_year,
teamkills_current_year,
updated_at
FROM player_search_index
WHERE server_id = ?
AND (
normalized_player_name LIKE ? ESCAPE '\\'
OR LOWER(player_id) LIKE LOWER(?)
)
ORDER BY
CASE
WHEN LOWER(player_id) = LOWER(?) THEN 0
WHEN normalized_player_name = ? THEN 1
WHEN normalized_player_name LIKE ? ESCAPE '\\' THEN 2
WHEN normalized_player_name LIKE ? ESCAPE '\\' THEN 3
ELSE 4
END,
matches_current_year DESC,
last_seen_at DESC,
player_name ASC
LIMIT ?
""",
[
resolved_server_id,
escaped_name_contains,
escaped_id_contains,
escaped_id_exact,
normalized_name_query,
escaped_name_prefix,
escaped_name_contains,
limit,
],
).fetchall()
except Exception:
return None, "player-search-index-unavailable"
items: list[dict[str, object]] = []
generated_at = None
for row in rows:
if generated_at is None:
generated_at = row["updated_at"]
items.append(
{
"player_id": str(row["player_id"] or "").strip(),
"player_name": str(row["player_name"] or row["player_id"] or "").strip(),
"matches_considered": int(row["matches_current_year"] or 0),
"last_seen_at": row["last_seen_at"],
"servers_seen": _deserialize_string_list(row["servers_seen"]),
}
)
return (
{
"server_id": resolved_server_id,
"query": normalized_query,
"items": items,
"source": {
"read_model": "player-search-index",
"fallback_used": False,
"fallback_reason": None,
"generated_at": generated_at,
"window_start": _to_iso(year_start),
"window_end": _to_iso(anchor),
},
},
None,
)
def _lookup_latest_player_names(
*,
db_path: Path,
@@ -426,6 +802,45 @@ def _lookup_latest_player_names(
return latest_names
def _lookup_latest_player_names_with_connection(
*,
connection: object,
player_ids: list[str],
server_id: str | None,
start: datetime,
end: datetime,
) -> dict[str, str]:
if not player_ids:
return {}
placeholders = ", ".join(["?"] * len(player_ids))
scope_sql, scope_params = _build_scope_sql(server_id)
rows = connection.execute(
f"""
SELECT
stats.player_id,
stats.player_name,
COALESCE(matches.ended_at, matches.started_at) AS last_seen_at
FROM rcon_match_player_stats AS stats
INNER JOIN rcon_materialized_matches AS matches
ON matches.target_key = stats.target_key
AND matches.match_key = stats.match_key
WHERE stats.player_id IN ({placeholders})
AND matches.source_basis = ?
AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) >= ?
AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) < ?
{scope_sql}
ORDER BY COALESCE(matches.ended_at, matches.started_at) DESC, stats.player_name ASC
""",
[*player_ids, MATCH_RESULT_SOURCE, _to_iso(start), _to_iso(end), *scope_params],
).fetchall()
latest_names: dict[str, str] = {}
for row in rows:
player_id = str(row["player_id"] or "").strip()
if player_id and player_id not in latest_names:
latest_names[player_id] = str(row["player_name"] or player_id)
return latest_names
def _lookup_servers_seen(
*,
db_path: Path,
@@ -465,6 +880,50 @@ def _lookup_servers_seen(
return by_player
def _lookup_servers_seen_with_connection(
*,
connection: object,
player_ids: list[str],
server_id: str | None,
start: datetime,
end: datetime,
) -> dict[str, list[str]]:
if not player_ids:
return {}
placeholders = ", ".join(["?"] * len(player_ids))
scope_sql, scope_params = _build_scope_sql(server_id)
rows = connection.execute(
f"""
SELECT
stats.player_id,
COALESCE(matches.external_server_id, matches.target_key) AS server_id
FROM rcon_match_player_stats AS stats
INNER JOIN rcon_materialized_matches AS matches
ON matches.target_key = stats.target_key
AND matches.match_key = stats.match_key
WHERE stats.player_id IN ({placeholders})
AND matches.source_basis = ?
AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) >= ?
AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) < ?
{scope_sql}
GROUP BY stats.player_id, COALESCE(matches.external_server_id, matches.target_key)
ORDER BY stats.player_id, server_id ASC
""",
[*player_ids, MATCH_RESULT_SOURCE, _to_iso(start), _to_iso(end), *scope_params],
).fetchall()
by_player: dict[str, list[str]] = {}
for row in rows:
player_id = str(row["player_id"] or "").strip()
if not player_id:
continue
by_player.setdefault(player_id, [])
candidate_server_id = str(row["server_id"] or "").strip()
if candidate_server_id and candidate_server_id not in by_player[player_id]:
by_player[player_id].append(candidate_server_id)
return by_player
def _build_scope_sql(
server_id: str | None,
*,
@@ -511,3 +970,84 @@ def _connect_scope(resolved_path: Path, *, db_path: Path | None = None):
return connect_postgres_compat()
return closing(connect_sqlite_readonly(resolved_path))
def _connect_write_scope(resolved_path: Path, *, db_path: Path | None = None):
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import connect_postgres_compat
return connect_postgres_compat()
return connect_sqlite_writer(resolved_path)
def _normalize_player_search_text(value: object) -> str:
text = str(value or "").strip().casefold()
if not text:
return ""
decomposed = unicodedata.normalize("NFKD", text)
without_accents = "".join(character for character in decomposed if not unicodedata.combining(character))
compact = " ".join(without_accents.split())
return compact
def _deserialize_string_list(value: object) -> list[str]:
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
if not isinstance(value, str) or not value.strip():
return []
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return []
if not isinstance(parsed, list):
return []
return [str(item).strip() for item in parsed if str(item).strip()]
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _json_default(value: object) -> str:
if isinstance(value, datetime):
return _to_iso(value)
if isinstance(value, date):
return value.isoformat()
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
def _main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Search and refresh player-search read models over materialized RCON stats.",
)
subparsers = parser.add_subparsers(dest="command")
refresh_parser = subparsers.add_parser("refresh-player-search-index")
refresh_parser.add_argument(
"--sqlite-path",
type=Path,
default=None,
help="explicit local SQLite override; default operational mode uses PostgreSQL when configured",
)
args = parser.parse_args(argv)
if args.command == "refresh-player-search-index":
payload = refresh_player_search_index(db_path=args.sqlite_path)
print(
json.dumps(
{"status": "ok", "data": payload},
ensure_ascii=True,
indent=2,
default=_json_default,
)
)
return 0
parser.print_help()
return 2
if __name__ == "__main__":
raise SystemExit(_main())