213 lines
6.0 KiB
Python
213 lines
6.0 KiB
Python
import argparse
|
|
import fcntl
|
|
import os
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
if str(PROJECT_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
from database.job_store import JobStore
|
|
from database.maintenance import (
|
|
check_managed_databases,
|
|
create_backup_set,
|
|
prune_backup_sets,
|
|
restore_backup_set,
|
|
)
|
|
from database.paths import L1_DB, L2_DB, PIPELINE_LOCK
|
|
|
|
|
|
STAGES = (
|
|
('l1', 15, Path('database/L1/L1_Builder.py')),
|
|
('l2', 55, Path('database/L2/L2_Builder.py')),
|
|
('l3', 85, Path('database/L3/L3_Builder.py')),
|
|
)
|
|
|
|
|
|
class PipelineError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def _run_stage(store, job_id, stage, progress, script_path, replace=False):
|
|
store.update_progress(
|
|
job_id,
|
|
stage,
|
|
progress,
|
|
f'Running {stage.upper()} builder',
|
|
)
|
|
command = [sys.executable, str(PROJECT_ROOT / script_path)]
|
|
if stage == 'l1' and replace:
|
|
command.append('--force')
|
|
if stage == 'l3':
|
|
command.append('--no-backup')
|
|
|
|
started = time.monotonic()
|
|
result = subprocess.run(
|
|
command,
|
|
cwd=str(PROJECT_ROOT),
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=1200,
|
|
)
|
|
duration = time.monotonic() - started
|
|
store.append_log(
|
|
job_id,
|
|
(
|
|
f'\n===== {stage.upper()} ({duration:.2f}s) =====\n'
|
|
f'{result.stdout}\n{result.stderr}'
|
|
),
|
|
)
|
|
if result.returncode != 0:
|
|
raise PipelineError(
|
|
f'{stage.upper()} builder exited with code {result.returncode}'
|
|
)
|
|
|
|
|
|
def _validate_pipeline_output(match_id=None):
|
|
database_status = check_managed_databases()
|
|
failures = [
|
|
f"{name}: {status['quick_check']}"
|
|
for name, status in database_status.items()
|
|
if status['quick_check'] != 'ok'
|
|
]
|
|
if failures:
|
|
raise PipelineError(
|
|
'Database quick_check failed: ' + ', '.join(failures)
|
|
)
|
|
|
|
if not match_id:
|
|
return
|
|
|
|
l1 = sqlite3.connect(str(L1_DB))
|
|
l2 = sqlite3.connect(str(L2_DB))
|
|
try:
|
|
raw_count = l1.execute(
|
|
'SELECT COUNT(*) FROM raw_iframe_network WHERE match_id = ?',
|
|
[match_id],
|
|
).fetchone()[0]
|
|
match_count = l2.execute(
|
|
'SELECT COUNT(*) FROM fact_matches WHERE match_id = ?',
|
|
[match_id],
|
|
).fetchone()[0]
|
|
player_count = l2.execute(
|
|
'SELECT COUNT(*) FROM fact_match_players WHERE match_id = ?',
|
|
[match_id],
|
|
).fetchone()[0]
|
|
round_count = l2.execute(
|
|
'SELECT COUNT(*) FROM fact_rounds WHERE match_id = ?',
|
|
[match_id],
|
|
).fetchone()[0]
|
|
finally:
|
|
l1.close()
|
|
l2.close()
|
|
|
|
if raw_count != 1:
|
|
raise PipelineError(f'L1 does not contain imported match {match_id}')
|
|
if match_count != 1:
|
|
raise PipelineError(f'L2 does not contain imported match {match_id}')
|
|
if player_count != 10:
|
|
raise PipelineError(
|
|
f'Imported match has {player_count} players; expected 10'
|
|
)
|
|
if round_count <= 0:
|
|
raise PipelineError('Imported match has no round facts')
|
|
|
|
|
|
def run_pipeline(job_id, match_id=None, replace=False):
|
|
store = JobStore()
|
|
job = store.get_job(job_id)
|
|
if not job:
|
|
raise PipelineError(f'Unknown ETL job: {job_id}')
|
|
|
|
started = time.monotonic()
|
|
backup_dir = None
|
|
PIPELINE_LOCK.parent.mkdir(parents=True, exist_ok=True)
|
|
lock_file = PIPELINE_LOCK.open('w')
|
|
try:
|
|
try:
|
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError as exc:
|
|
raise PipelineError('Another database pipeline is already running') from exc
|
|
|
|
store.start_job(job_id, 'backup', 'Creating rollback snapshot')
|
|
backup_dir = create_backup_set(f'job-{job_id}')
|
|
store.append_log(job_id, f'Backup created: {backup_dir}\n')
|
|
|
|
for stage, progress, script_path in STAGES:
|
|
_run_stage(
|
|
store,
|
|
job_id,
|
|
stage,
|
|
progress,
|
|
script_path,
|
|
replace=replace,
|
|
)
|
|
|
|
store.update_progress(
|
|
job_id,
|
|
'validation',
|
|
95,
|
|
'Validating imported data',
|
|
)
|
|
_validate_pipeline_output(match_id)
|
|
removed_backups = prune_backup_sets(keep=3)
|
|
if removed_backups:
|
|
store.append_log(
|
|
job_id,
|
|
f"Pruned old backups: {', '.join(removed_backups)}\n",
|
|
)
|
|
duration = time.monotonic() - started
|
|
store.finish_job(
|
|
job_id,
|
|
True,
|
|
'Pipeline completed and validated',
|
|
duration,
|
|
)
|
|
return True
|
|
except Exception as exc:
|
|
store.append_log(job_id, f'\nPIPELINE FAILED: {exc}\n')
|
|
if backup_dir:
|
|
try:
|
|
restore_backup_set(backup_dir)
|
|
store.append_log(job_id, 'Rollback snapshot restored successfully\n')
|
|
except Exception as restore_exc:
|
|
store.append_log(
|
|
job_id,
|
|
f'ROLLBACK FAILED: {restore_exc}\n',
|
|
)
|
|
exc = PipelineError(f'{exc}; rollback also failed: {restore_exc}')
|
|
store.finish_job(
|
|
job_id,
|
|
False,
|
|
str(exc),
|
|
time.monotonic() - started,
|
|
)
|
|
return False
|
|
finally:
|
|
try:
|
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
finally:
|
|
lock_file.close()
|
|
|
|
|
|
def _parse_args():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('--job-id', type=int, required=True)
|
|
parser.add_argument('--match-id')
|
|
parser.add_argument('--replace', action='store_true')
|
|
return parser.parse_args()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
args = _parse_args()
|
|
succeeded = run_pipeline(
|
|
args.job_id,
|
|
match_id=args.match_id,
|
|
replace=args.replace,
|
|
)
|
|
raise SystemExit(0 if succeeded else 1)
|