oem_crawler/scripts/migrate_raw_payloads.py
2026-11-07 16:53:30 +01:00

259 lines
8.7 KiB
Python
Executable file

#!/usr/bin/env python3
"""
Migration script to extract raw OEM payloads from SQLite into external compressed .json.gz files,
update vehicles with raw_payload_path, clear in-database raw JSON, and compact the database via VACUUM.
"""
from __future__ import annotations
import argparse
import gzip
import json
import logging
import os
import sqlite3
import sys
import time
from pathlib import Path
from typing import Any, Dict, Optional
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("migrate_raw_payloads")
def format_bytes(num_bytes: int | float) -> str:
"""Formats bytes into human-readable string (KB, MB, GB)."""
for unit in ["B", "KB", "MB", "GB", "TB"]:
if abs(num_bytes) < 1024.0:
return f"{num_bytes:.2f} {unit}"
num_bytes /= 1024.0
return f"{num_bytes:.2f} PB"
def sanitize_filename(name: str) -> str:
"""Sanitizes an OEM vehicle id for filesystem safety."""
return "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in str(name))
def migrate_database(
db_path: Path,
raw_dir: Path,
compression_level: int = 6,
batch_size: int = 500,
dry_run: bool = False,
skip_vacuum: bool = False,
) -> None:
if not db_path.exists():
logger.error(f"Database file not found: {db_path}")
sys.exit(1)
initial_db_size = db_path.stat().st_size
logger.info(f"Target Database: {db_path} (Size: {format_bytes(initial_db_size)})")
logger.info(f"Storage Directory: {raw_dir.resolve()}")
logger.info(f"Dry Run Mode: {dry_run}")
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# 1. Ensure raw_payload_path column exists
cursor.execute("PRAGMA table_info(vehicles);")
columns = [row["name"] for row in cursor.fetchall()]
if "raw_payload_path" not in columns:
if dry_run:
logger.info("[Dry Run] Would execute: ALTER TABLE vehicles ADD COLUMN raw_payload_path VARCHAR(512);")
else:
logger.info("Adding 'raw_payload_path' column to vehicles table...")
cursor.execute("ALTER TABLE vehicles ADD COLUMN raw_payload_path VARCHAR(512);")
conn.commit()
# 2. Count vehicles requiring migration
cursor.execute("""
SELECT COUNT(*) as count
FROM vehicles
WHERE raw_payload IS NOT NULL AND length(raw_payload) > 2;
""")
total_vehicles = cursor.fetchone()["count"]
logger.info(f"Found {total_vehicles:,} vehicles with raw payload to extract.")
cursor.execute("""
SELECT COUNT(*) as count
FROM vehicle_options
WHERE raw_data IS NOT NULL AND raw_data != '{}';
""")
total_options = cursor.fetchone()["count"]
logger.info(f"Found {total_options:,} vehicle options with redundant raw_data.")
if total_vehicles == 0 and total_options == 0:
logger.info("No vehicles or options require migration.")
if not skip_vacuum and not dry_run:
logger.info("Running VACUUM to ensure database is compact...")
cursor.execute("VACUUM;")
conn.close()
return
if dry_run:
logger.info("[Dry Run] Migration simulation complete. No changes written.")
conn.close()
return
# 3. Process vehicles in batches
raw_dir.mkdir(parents=True, exist_ok=True)
migrated_count = 0
total_uncompressed_bytes = 0
total_compressed_bytes = 0
start_time = time.time()
cursor.execute("""
SELECT id, brand, oem_vehicle_id, created_at, last_seen_at, raw_payload, raw_payload_path
FROM vehicles
WHERE raw_payload IS NOT NULL AND length(raw_payload) > 2;
""")
updates_batch = []
while True:
rows = cursor.fetchmany(batch_size)
if not rows:
break
for row in rows:
v_id = row["id"]
brand = (row["brand"] or "unknown").strip().lower()
oem_id = row["oem_vehicle_id"] or v_id
created_at = str(row["last_seen_at"] or row["created_at"] or "")
date_str = created_at[:10] if len(created_at) >= 10 else "archive"
raw_str = row["raw_payload"]
if not raw_str or raw_str == "{}":
continue
try:
payload = json.loads(raw_str) if isinstance(raw_str, str) else raw_str
except Exception as e:
logger.warning(f"Could not parse JSON for vehicle {v_id}: {e}")
continue
# Compute relative path
safe_id = sanitize_filename(oem_id)
rel_path = f"{brand}/{date_str}/{safe_id}.json.gz"
dest_file = raw_dir / rel_path
dest_file.parent.mkdir(parents=True, exist_ok=True)
# Compress and write
json_bytes = json.dumps(payload, ensure_ascii=False).encode("utf-8")
compressed_data = gzip.compress(json_bytes, compresslevel=compression_level)
with open(dest_file, "wb") as f:
f.write(compressed_data)
total_uncompressed_bytes += len(json_bytes)
total_compressed_bytes += len(compressed_data)
migrated_count += 1
updates_batch.append((rel_path, v_id))
# Commit DB updates for batch
if updates_batch:
update_cursor = conn.cursor()
update_cursor.executemany(
"UPDATE vehicles SET raw_payload_path = ?, raw_payload = '{}' WHERE id = ?;",
updates_batch,
)
conn.commit()
updates_batch.clear()
elapsed = time.time() - start_time
rate = migrated_count / elapsed if elapsed > 0 else 0
logger.info(
f"Progress: {migrated_count:,}/{total_vehicles:,} vehicles ({migrated_count/total_vehicles*100:.1f}%) "
f"| Rate: {rate:.0f} veh/sec"
)
# 4. Clean vehicle_options raw_data
logger.info("Clearing redundant raw_data in vehicle_options...")
cursor.execute("UPDATE vehicle_options SET raw_data = '{}' WHERE raw_data != '{}';")
conn.commit()
# 5. Compact the database with VACUUM
if not skip_vacuum:
logger.info("Running VACUUM to reclaim free space (this may take a few moments)...")
vac_start = time.time()
cursor.execute("VACUUM;")
logger.info(f"VACUUM completed in {time.time() - vac_start:.1f}s.")
conn.close()
final_db_size = db_path.stat().st_size
reclaimed_bytes = initial_db_size - final_db_size
# Summary
logger.info("\n" + "=" * 60)
logger.info("MIGRATION COMPLETED SUCCESSFULLY")
logger.info("=" * 60)
logger.info(f"Vehicles Migrated: {migrated_count:,}")
logger.info(f"Options Cleaned: {total_options:,}")
logger.info(f"Uncompressed Payload Size:{format_bytes(total_uncompressed_bytes)}")
logger.info(f"Compressed File Size: {format_bytes(total_compressed_bytes)} "
f"({total_compressed_bytes / max(total_uncompressed_bytes, 1) * 100:.1f}%)")
logger.info(f"Original DB Size: {format_bytes(initial_db_size)}")
logger.info(f"New DB Size: {format_bytes(final_db_size)}")
logger.info(f"Space Reclaimed: {format_bytes(reclaimed_bytes)} "
f"({reclaimed_bytes / max(initial_db_size, 1) * 100:.1f}% reduction)")
logger.info("=" * 60)
def main():
parser = argparse.ArgumentParser(description="Extract raw JSON payloads from SQLite to external compressed files")
parser.add_argument(
"--db-path",
type=Path,
default=Path("data/crawler_stock.db"),
help="Path to SQLite database file (default: data/crawler_stock.db)",
)
parser.add_argument(
"--raw-dir",
type=Path,
default=Path("data/raw"),
help="Destination directory for compressed JSON files (default: data/raw)",
)
parser.add_argument(
"--compression-level",
type=int,
default=6,
help="Gzip compression level (1-9, default: 6)",
)
parser.add_argument(
"--batch-size",
type=int,
default=500,
help="Batch size for processing and DB updates (default: 500)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Inspect database without writing files or modifying database",
)
parser.add_argument(
"--skip-vacuum",
action="store_true",
help="Skip database VACUUM at end of migration",
)
args = parser.parse_args()
migrate_database(
db_path=args.db_path,
raw_dir=args.raw_dir,
compression_level=args.compression_level,
batch_size=args.batch_size,
dry_run=args.dry_run,
skip_vacuum=args.skip_vacuum,
)
if __name__ == "__main__":
main()