Files
JKTV-online/web/services/integrity_service.py
T

675 lines
23 KiB
Python

from datetime import datetime, timezone
import json
import os
import sqlite3
from database.maintenance import backup_storage_status
from web.config import Config
from web.services.team_context_service import TeamContextService
class IntegrityService:
DATABASES = {
'L2': Config.DB_L2_PATH,
'L3': Config.DB_L3_PATH,
'Web': Config.DB_WEB_PATH,
}
@staticmethod
def _check(checks, name, status, detail, value=None):
checks.append({
'name': name,
'status': status,
'detail': detail,
'value': value,
})
@staticmethod
def _connect(path):
db = sqlite3.connect(path, timeout=Config.SQLITE_TIMEOUT_SECONDS)
db.row_factory = sqlite3.Row
return db
@staticmethod
def build_report():
checks = []
counts = {}
connections = {}
try:
for name, path in IntegrityService.DATABASES.items():
if not os.path.exists(path):
IntegrityService._check(
checks,
f'{name} database',
'fail',
f'Missing file: {path}',
)
continue
try:
db = IntegrityService._connect(path)
connections[name] = db
result = db.execute('PRAGMA quick_check').fetchone()[0]
IntegrityService._check(
checks,
f'{name} database',
'pass' if result == 'ok' else 'fail',
f'quick_check: {result}',
os.path.getsize(path),
)
except sqlite3.Error as exc:
IntegrityService._check(
checks,
f'{name} database',
'fail',
str(exc),
)
l2 = connections.get('L2')
if l2:
IntegrityService._check_l2(l2, checks, counts)
l3 = connections.get('L3')
roster_ids = TeamContextService.get_active_roster_ids()
counts['active_roster'] = len(roster_ids)
if l3:
IntegrityService._check_l3(l3, roster_ids, checks, counts)
web = connections.get('Web')
if web:
IntegrityService._check_web(web, checks, counts)
backup_status = backup_storage_status()
counts['backup_sets'] = backup_status['sets']
counts['backup_bytes'] = backup_status['total_bytes']
IntegrityService._check(
checks,
'Backup retention',
'warn' if backup_status['sets'] > 3 else 'pass',
(
f"{backup_status['sets']} backup sets, "
f"{backup_status['total_bytes']:,} bytes"
),
backup_status['sets'],
)
finally:
for db in connections.values():
db.close()
status_order = {'pass': 0, 'warn': 1, 'fail': 2}
overall_status = max(
(check['status'] for check in checks),
key=lambda status: status_order[status],
default='fail',
)
return {
'generated_at': datetime.now(timezone.utc).isoformat(),
'overall_status': overall_status,
'counts': counts,
'checks': checks,
'totals': {
status: sum(1 for check in checks if check['status'] == status)
for status in ('pass', 'warn', 'fail')
},
}
@staticmethod
def _table_names(db):
return {
row[0]
for row in db.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)
}
@staticmethod
def _check_l2(db, checks, counts):
required_tables = {
'dim_players',
'fact_matches',
'fact_match_teams',
'fact_match_players',
'fact_rounds',
'fact_round_events',
'fact_round_player_economy',
}
missing = sorted(required_tables - IntegrityService._table_names(db))
IntegrityService._check(
checks,
'L2 required tables',
'fail' if missing else 'pass',
f"Missing: {', '.join(missing)}" if missing else 'All required tables exist',
)
if missing:
return
table_count_map = {
'matches': 'fact_matches',
'players': 'dim_players',
'player_match_rows': 'fact_match_players',
'rounds': 'fact_rounds',
'events': 'fact_round_events',
'economy_rows': 'fact_round_player_economy',
}
for key, table in table_count_map.items():
counts[key] = db.execute(f'SELECT COUNT(*) FROM {table}').fetchone()[0]
orphan_players = db.execute(
"""
SELECT COUNT(*)
FROM fact_match_players mp
LEFT JOIN fact_matches m ON m.match_id = mp.match_id
WHERE m.match_id IS NULL
"""
).fetchone()[0]
IntegrityService._check(
checks,
'Player-match referential integrity',
'fail' if orphan_players else 'pass',
f'{orphan_players} player rows reference missing matches',
orphan_players,
)
orphan_events = db.execute(
"""
SELECT COUNT(*)
FROM fact_round_events e
LEFT JOIN fact_rounds r
ON r.match_id = e.match_id AND r.round_num = e.round_num
WHERE r.match_id IS NULL
"""
).fetchone()[0]
IntegrityService._check(
checks,
'Round-event referential integrity',
'fail' if orphan_events else 'pass',
f'{orphan_events} events reference missing rounds',
orphan_events,
)
unusual_rosters = db.execute(
"""
SELECT COUNT(*)
FROM (
SELECT match_id, COUNT(*) AS player_count
FROM fact_match_players
GROUP BY match_id
HAVING player_count != 10
)
"""
).fetchone()[0]
IntegrityService._check(
checks,
'Match player cardinality',
'warn' if unusual_rosters else 'pass',
f'{unusual_rosters} matches do not contain exactly 10 players',
unusual_rosters,
)
missing_names = db.execute(
"SELECT COUNT(*) FROM dim_players WHERE username IS NULL OR TRIM(username) = ''"
).fetchone()[0]
IntegrityService._check(
checks,
'Player identity coverage',
'warn' if missing_names else 'pass',
f'{missing_names} players have no username',
missing_names,
)
required_indexes = {
'idx_match_players_player_match',
'idx_match_players_match_team',
'idx_match_players_party',
'idx_round_events_victim',
'idx_economy_player_match',
'idx_matches_map_time',
}
existing_indexes = {
row[0] for row in db.execute(
"SELECT name FROM sqlite_master WHERE type = 'index'"
)
}
missing_indexes = sorted(required_indexes - existing_indexes)
IntegrityService._check(
checks,
'L2 operational indexes',
'fail' if missing_indexes else 'pass',
(
f"Missing: {', '.join(missing_indexes)}"
if missing_indexes else
'All high-frequency query indexes exist'
),
len(required_indexes) - len(missing_indexes),
)
@staticmethod
def _check_l3(db, roster_ids, checks, counts):
required_tables = {
'dm_discovery_insights',
'dm_duo_stats',
'dm_match_player_reports',
'dm_match_reports',
'dm_player_features',
'dm_player_awards',
'dm_player_match_history',
'dm_player_map_stats',
'dm_player_period_stats',
'dm_player_record_events',
'dm_player_records',
'dm_player_weapon_stats',
'dm_performance_medals',
'dm_lineup_stats',
'dm_team_season_stats',
}
missing = sorted(required_tables - IntegrityService._table_names(db))
IntegrityService._check(
checks,
'L3 required tables',
'fail' if missing else 'pass',
f"Missing: {', '.join(missing)}" if missing else 'All required tables exist',
)
if missing:
return
counts['l3_features'] = db.execute(
'SELECT COUNT(*) FROM dm_player_features'
).fetchone()[0]
counts['l3_history'] = db.execute(
'SELECT COUNT(*) FROM dm_player_match_history'
).fetchone()[0]
counts['l3_maps'] = db.execute(
'SELECT COUNT(*) FROM dm_player_map_stats'
).fetchone()[0]
counts['l3_weapons'] = db.execute(
'SELECT COUNT(*) FROM dm_player_weapon_stats'
).fetchone()[0]
counts['l3_periods'] = db.execute(
'SELECT COUNT(*) FROM dm_player_period_stats'
).fetchone()[0]
counts['l3_records'] = db.execute(
'SELECT COUNT(*) FROM dm_player_records'
).fetchone()[0]
counts['l3_duos'] = db.execute(
'SELECT COUNT(*) FROM dm_duo_stats'
).fetchone()[0]
counts['l3_lineups'] = db.execute(
'SELECT COUNT(*) FROM dm_lineup_stats'
).fetchone()[0]
counts['l3_match_reports'] = db.execute(
'SELECT COUNT(*) FROM dm_match_reports'
).fetchone()[0]
counts['l3_player_reports'] = db.execute(
'SELECT COUNT(*) FROM dm_match_player_reports'
).fetchone()[0]
counts['l3_record_events'] = db.execute(
'SELECT COUNT(*) FROM dm_player_record_events'
).fetchone()[0]
counts['l3_seasons'] = db.execute(
'SELECT COUNT(*) FROM dm_team_season_stats'
).fetchone()[0]
counts['l3_awards'] = db.execute(
'SELECT COUNT(*) FROM dm_player_awards'
).fetchone()[0]
counts['l3_discoveries'] = db.execute(
'SELECT COUNT(*) FROM dm_discovery_insights'
).fetchone()[0]
counts['l3_medals'] = db.execute(
'SELECT COUNT(*) FROM dm_performance_medals'
).fetchone()[0]
expected_matches = counts.get('matches', 0)
IntegrityService._check(
checks,
'Post-match report coverage',
(
'pass'
if counts['l3_match_reports'] == expected_matches
else 'fail'
),
(
f"{counts['l3_match_reports']}/"
f"{expected_matches} matches reported"
),
counts['l3_match_reports'],
)
if roster_ids:
placeholders = ','.join('?' for _ in roster_ids)
covered = db.execute(
f"""
SELECT COUNT(DISTINCT steam_id_64)
FROM dm_player_features
WHERE steam_id_64 IN ({placeholders})
""",
roster_ids,
).fetchone()[0]
IntegrityService._check(
checks,
'Active roster feature coverage',
'pass' if covered == len(roster_ids) else 'fail',
f'{covered}/{len(roster_ids)} roster players have L3 features',
covered,
)
expected_history = db.execute(
f"""
SELECT COALESCE(SUM(total_matches), 0)
FROM dm_player_features
WHERE steam_id_64 IN ({placeholders})
""",
roster_ids,
).fetchone()[0]
actual_history = db.execute(
f"""
SELECT COUNT(*)
FROM dm_player_match_history
WHERE steam_id_64 IN ({placeholders})
""",
roster_ids,
).fetchone()[0]
IntegrityService._check(
checks,
'Roster history completeness',
'pass' if actual_history == expected_history else 'fail',
f'{actual_history}/{expected_history} player-match rows materialized',
actual_history,
)
player_report_count = db.execute(
f"""
SELECT COUNT(*)
FROM dm_match_player_reports
WHERE steam_id_64 IN ({placeholders})
""",
roster_ids,
).fetchone()[0]
IntegrityService._check(
checks,
'Roster player-report completeness',
'pass' if player_report_count == expected_history else 'fail',
f'{player_report_count}/{expected_history} player reports',
player_report_count,
)
score_rows = db.execute(
f"""
SELECT steam_id_64, score_overall, tier_percentile
FROM dm_player_features
WHERE steam_id_64 IN ({placeholders})
AND score_overall > 0
""",
roster_ids,
).fetchall()
scores = [float(row['score_overall']) for row in score_rows]
invalid_percentiles = 0
for row in score_rows:
expected = (
sum(value <= float(row['score_overall']) for value in scores)
/ len(scores)
* 100
)
actual = row['tier_percentile']
if actual is None or abs(float(actual) - expected) > 0.011:
invalid_percentiles += 1
IntegrityService._check(
checks,
'Roster percentile correctness',
'pass' if invalid_percentiles == 0 else 'warn',
f'{invalid_percentiles} eligible players have stale percentiles',
invalid_percentiles,
)
for table, label in (
('dm_player_period_stats', 'Roster period-stat coverage'),
('dm_player_records', 'Roster record coverage'),
):
covered = db.execute(
f"""
SELECT COUNT(DISTINCT steam_id_64)
FROM {table}
WHERE steam_id_64 IN ({placeholders})
""",
roster_ids,
).fetchone()[0]
IntegrityService._check(
checks,
label,
'pass' if covered == len(roster_ids) else 'fail',
f'{covered}/{len(roster_ids)} roster players covered',
covered,
)
for key, label in (
('l3_history', 'Player match history mart'),
('l3_maps', 'Player map stats mart'),
('l3_weapons', 'Player weapon stats mart'),
('l3_periods', 'Player period stats mart'),
('l3_records', 'Player records mart'),
('l3_duos', 'Team duo stats mart'),
('l3_lineups', 'Team lineup stats mart'),
('l3_match_reports', 'Post-match report mart'),
('l3_player_reports', 'Player post-match report mart'),
('l3_record_events', 'Record event mart'),
('l3_seasons', 'Team season mart'),
('l3_awards', 'Player award mart'),
('l3_discoveries', 'Discovery insight mart'),
('l3_medals', 'Map and ELO medal mart'),
):
value = counts[key]
IntegrityService._check(
checks,
label,
'pass' if value else 'warn',
f'{value} rows',
value,
)
discovery_tones = {
row[0] for row in db.execute(
'SELECT DISTINCT tone FROM dm_discovery_insights'
)
}
IntegrityService._check(
checks,
'Discovery tone coverage',
(
'pass'
if discovery_tones == {'positive', 'negative', 'fun'}
else 'fail'
),
f"tones: {', '.join(sorted(discovery_tones))}",
len(discovery_tones),
)
invalid_medals = db.execute(
"""
SELECT COUNT(*)
FROM dm_performance_medals
WHERE matches < 5
OR medal_rank NOT BETWEEN 1 AND 3
"""
).fetchone()[0]
duplicate_medals = db.execute(
"""
SELECT COUNT(*)
FROM (
SELECT dimension_type, dimension_key, medal_rank, COUNT(*) AS n
FROM dm_performance_medals
GROUP BY dimension_type, dimension_key, medal_rank
HAVING n > 1
)
"""
).fetchone()[0]
IntegrityService._check(
checks,
'Performance medal validity',
'pass' if invalid_medals == 0 and duplicate_medals == 0 else 'fail',
(
f'{invalid_medals} invalid samples, '
f'{duplicate_medals} duplicate ranks'
),
invalid_medals + duplicate_medals,
)
if roster_ids:
placeholders = ','.join('?' for _ in roster_ids)
scope_sql = f"AND steam_id_64 IN ({placeholders})"
scope_args = roster_ids
else:
scope_sql = ''
scope_args = []
placeholder_rows = db.execute(
f"""
SELECT COUNT(*)
FROM dm_player_features
WHERE int_pos_site_a_control_rate = 0.33
AND int_pos_site_b_control_rate = 0.33
AND int_pos_mid_control_rate = 0.34
{scope_sql}
""",
scope_args,
).fetchone()[0]
IntegrityService._check(
checks,
'Experimental spatial metrics',
'warn' if placeholder_rows else 'pass',
f'{placeholder_rows} active-roster rows contain placeholder site-control values',
placeholder_rows,
)
@staticmethod
def _check_web(db, checks, counts):
required_tables = {
'comments',
'etl_jobs',
'match_imports',
'player_metadata',
'schema_migrations',
'strategy_boards',
'team_lineups',
'team_roster_members',
'team_roster_versions',
'wiki_pages',
}
missing = sorted(required_tables - IntegrityService._table_names(db))
IntegrityService._check(
checks,
'Web required tables',
'fail' if missing else 'pass',
f"Missing: {', '.join(missing)}" if missing else 'All required tables exist',
)
if missing:
return
counts['etl_jobs'] = db.execute(
'SELECT COUNT(*) FROM etl_jobs'
).fetchone()[0]
counts['match_imports'] = db.execute(
'SELECT COUNT(*) FROM match_imports'
).fetchone()[0]
schema_version = db.execute(
'SELECT COALESCE(MAX(version), 0) FROM schema_migrations'
).fetchone()[0]
counts['web_schema_version'] = schema_version
IntegrityService._check(
checks,
'Web schema version',
'pass' if schema_version == Config.WEB_SCHEMA_VERSION else 'fail',
f'{schema_version}/{Config.WEB_SCHEMA_VERSION}',
schema_version,
)
foreign_key_errors = db.execute(
'PRAGMA foreign_key_check'
).fetchall()
IntegrityService._check(
checks,
'Web foreign key integrity',
'fail' if foreign_key_errors else 'pass',
f'{len(foreign_key_errors)} foreign key violations',
len(foreign_key_errors),
)
running_jobs = db.execute(
"""
SELECT COUNT(*)
FROM etl_jobs
WHERE status = 'running'
"""
).fetchone()[0]
IntegrityService._check(
checks,
'Pipeline concurrency',
'warn' if running_jobs > 1 else 'pass',
f'{running_jobs} running pipeline jobs',
running_jobs,
)
lineups = db.execute(
'SELECT id, player_ids_json, is_active FROM team_lineups'
).fetchall()
counts['lineups'] = len(lineups)
invalid_lineups = 0
for lineup in lineups:
try:
player_ids = json.loads(lineup['player_ids_json'] or '[]')
if not isinstance(player_ids, list):
invalid_lineups += 1
except (TypeError, json.JSONDecodeError):
invalid_lineups += 1
IntegrityService._check(
checks,
'Lineup JSON validity',
'fail' if invalid_lineups else 'pass',
f'{invalid_lineups} lineups contain invalid player ID JSON',
invalid_lineups,
)
active_count = sum(1 for lineup in lineups if lineup['is_active'] == 1)
IntegrityService._check(
checks,
'Active lineup',
'pass' if active_count == 1 else 'warn',
f'{active_count} active lineups configured',
active_count,
)
current_versions = db.execute(
"""
SELECT id
FROM team_roster_versions
WHERE is_current = 1
"""
).fetchall()
current_member_count = 0
if len(current_versions) == 1:
current_member_count = db.execute(
"""
SELECT COUNT(*)
FROM team_roster_members
WHERE roster_version_id = ?
""",
[current_versions[0]['id']],
).fetchone()[0]
counts['roster_versions'] = db.execute(
'SELECT COUNT(*) FROM team_roster_versions'
).fetchone()[0]
counts['current_roster_members'] = current_member_count
IntegrityService._check(
checks,
'Current roster version',
'pass' if len(current_versions) == 1 else 'fail',
f'{len(current_versions)} current roster versions',
len(current_versions),
)
IntegrityService._check(
checks,
'Roster version membership',
'pass' if current_member_count == counts.get('active_roster', 0) else 'fail',
(
f"{current_member_count}/"
f"{counts.get('active_roster', 0)} active members versioned"
),
current_member_count,
)