259 lines
8.7 KiB
Python
Executable file
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()
|
|
|