675 lines
23 KiB
Python
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,
|
|
)
|