Files
2026-08-08 21:31:56 +08:00

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)