Files
Streamlit_NBA/scripts/db_storage.py
T
nprasad2077 3d5de1dc3e
test / test (push) Successful in 9s
complete app
2026-09-17 17:39:22 -05:00

303 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""
S3/Cloudflare R2 Object Storage utility for DuckDB database management.
Supports:
- Cloudflare R2 (recommended: zero egress fees)
- Hetzner S3 Object Storage
- SeaweedFS / MinIO / AWS S3
Commands:
python scripts/db_storage.py status
python scripts/db_storage.py upload [--file PATH] [--key KEY]
python scripts/db_storage.py download [--force] [--key KEY]
"""
import argparse
import hashlib
import os
import shutil
import sys
import time
from pathlib import Path
try:
from dotenv import load_dotenv
# Load .env file from repo root
repo_root = Path(__file__).resolve().parent.parent
load_dotenv(repo_root / ".env")
except ImportError:
repo_root = Path(__file__).resolve().parent.parent
try:
import boto3
from boto3.s3.transfer import TransferConfig
from botocore.client import Config
from botocore.exceptions import ClientError
except ImportError:
print("ERROR: boto3 is not installed. Please run `uv sync` to install dependencies.", file=sys.stderr)
sys.exit(1)
# Default Paths
DEFAULT_LOCAL_DB = repo_root / "data" / "DB" / "dbt_nba.duckdb"
DEFAULT_REPORTS_DB = repo_root / "dbt_nba" / "reports" / "sources" / "nba" / "dbt_nba.duckdb"
# Tuned TransferConfig for resilient large multipart transfers over HTTPS
TRANSFER_CONFIG = TransferConfig(
multipart_threshold=32 * 1024 * 1024, # 32 MB
max_concurrency=4, # 4 concurrent threads
multipart_chunksize=32 * 1024 * 1024, # 32 MB per chunk
use_threads=True,
num_download_attempts=5,
)
def get_s3_config():
"""Retrieve S3/R2 configuration from environment variables."""
endpoint_url = os.getenv("S3_ENDPOINT_URL") or os.getenv("R2_ENDPOINT_URL")
bucket_name = os.getenv("S3_BUCKET_NAME") or os.getenv("R2_BUCKET_NAME")
access_key = os.getenv("S3_ACCESS_KEY_ID") or os.getenv("AWS_ACCESS_KEY_ID") or os.getenv("R2_ACCESS_KEY_ID")
secret_key = os.getenv("S3_SECRET_ACCESS_KEY") or os.getenv("AWS_SECRET_ACCESS_KEY") or os.getenv("R2_SECRET_ACCESS_KEY")
region_name = os.getenv("S3_REGION", "auto")
db_key = os.getenv("S3_DB_KEY", "dbt_nba.duckdb")
missing = []
if not endpoint_url:
missing.append("S3_ENDPOINT_URL")
if not bucket_name:
missing.append("S3_BUCKET_NAME")
if not access_key:
missing.append("S3_ACCESS_KEY_ID")
if not secret_key:
missing.append("S3_SECRET_ACCESS_KEY")
if missing:
print(f"ERROR: Missing required environment variables in .env: {', '.join(missing)}", file=sys.stderr)
print("Please check .env.example for guidance.", file=sys.stderr)
sys.exit(1)
return {
"endpoint_url": endpoint_url,
"bucket_name": bucket_name,
"access_key": access_key,
"secret_key": secret_key,
"region_name": region_name,
"db_key": db_key,
}
def get_s3_client(config):
"""Create a boto3 S3 client configured for custom S3/R2 endpoints."""
return boto3.client(
"s3",
endpoint_url=config["endpoint_url"],
aws_access_key_id=config["access_key"],
aws_secret_access_key=config["secret_key"],
region_name=config["region_name"],
config=Config(
signature_version="s3v4",
s3={"addressing_style": "path"},
retries={"max_attempts": 5, "mode": "adaptive"},
),
)
class ProgressPercentage:
"""Console progress tracker for S3 file transfer."""
def __init__(self, filename, total_size, action="Transferring"):
self._filename = filename
self._total_size = float(total_size) if total_size > 0 else 1.0
self._seen_so_far = 0
self._start_time = time.time()
self._action = action
def __call__(self, bytes_amount):
self._seen_so_far += bytes_amount
percentage = (self._seen_so_far / self._total_size) * 100
elapsed = max(time.time() - self._start_time, 0.001)
speed_mb = (self._seen_so_far / (1024 * 1024)) / elapsed
mb_seen = self._seen_so_far / (1024 * 1024)
mb_total = self._total_size / (1024 * 1024)
sys.stdout.write(
f"\r{self._action} {Path(self._filename).name}: {mb_seen:.1f}/{mb_total:.1f} MB "
f"({percentage:5.1f}%) @ {speed_mb:5.1f} MB/s"
)
sys.stdout.flush()
if self._seen_so_far >= self._total_size:
sys.stdout.write("\n")
sys.stdout.flush()
def compute_file_md5(filepath: Path) -> str:
"""Compute hex MD5 hash of a local file."""
hash_md5 = hashlib.md5()
with open(filepath, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
hash_md5.update(chunk)
return hash_md5.hexdigest()
def upload_db(file_path: Path = None, key: str = None):
"""Upload DuckDB file to S3/R2 with auto-retry."""
config = get_s3_config()
target_file = Path(file_path) if file_path else DEFAULT_LOCAL_DB
object_key = key or config["db_key"]
if not target_file.exists():
print(f"ERROR: Local database file not found at {target_file}", file=sys.stderr)
sys.exit(1)
file_size = target_file.stat().st_size
print(f"==> Uploading {target_file} ({file_size / (1024*1024):.2f} MB) to s3://{config['bucket_name']}/{object_key}...")
s3 = get_s3_client(config)
max_retries = 3
for attempt in range(1, max_retries + 1):
try:
progress = ProgressPercentage(str(target_file), file_size, action="Uploading")
s3.upload_file(
Filename=str(target_file),
Bucket=config["bucket_name"],
Key=object_key,
Config=TRANSFER_CONFIG,
Callback=progress,
)
print(f"✓ Successfully uploaded {object_key} to {config['bucket_name']}")
return
except Exception as e:
if attempt < max_retries:
print(f"\n[Attempt {attempt}/{max_retries}] Upload encountered error ({e}). Retrying in 3s...", file=sys.stderr)
time.sleep(3)
else:
print(f"\nERROR: Failed to upload to S3 after {max_retries} attempts: {e}", file=sys.stderr)
sys.exit(1)
def download_db(force: bool = False, key: str = None):
"""Download DuckDB file from S3/R2 to local destinations with auto-retry."""
config = get_s3_config()
object_key = key or config["db_key"]
s3 = get_s3_client(config)
print(f"==> Checking remote object s3://{config['bucket_name']}/{object_key}...")
try:
head = s3.head_object(Bucket=config["bucket_name"], Key=object_key)
except ClientError as e:
print(f"ERROR: Could not find or access remote object {object_key} in {config['bucket_name']}: {e}", file=sys.stderr)
sys.exit(1)
remote_size = head.get("ContentLength", 0)
last_modified = head.get("LastModified")
print(f" Remote size: {remote_size / (1024*1024):.2f} MB | Last modified: {last_modified}")
# Check if local file is already identical
if not force and DEFAULT_LOCAL_DB.exists() and DEFAULT_LOCAL_DB.stat().st_size == remote_size:
# Check if reports db also exists
if DEFAULT_REPORTS_DB.exists() and DEFAULT_REPORTS_DB.stat().st_size == remote_size:
print("✓ Local database is already up-to-date. Skipping download (use --force to re-download).")
return
# Ensure parent directories exist
DEFAULT_LOCAL_DB.parent.mkdir(parents=True, exist_ok=True)
DEFAULT_REPORTS_DB.parent.mkdir(parents=True, exist_ok=True)
temp_file = DEFAULT_LOCAL_DB.with_suffix(".duckdb.tmp")
max_retries = 3
for attempt in range(1, max_retries + 1):
try:
progress = ProgressPercentage(str(DEFAULT_LOCAL_DB), remote_size, action="Downloading")
s3.download_file(
Bucket=config["bucket_name"],
Key=object_key,
Filename=str(temp_file),
Config=TRANSFER_CONFIG,
Callback=progress,
)
# Atomically move temp file to main db path
shutil.move(str(temp_file), str(DEFAULT_LOCAL_DB))
# Copy to reports location as well
shutil.copy2(str(DEFAULT_LOCAL_DB), str(DEFAULT_REPORTS_DB))
print(f"✓ Downloaded database to:\n - {DEFAULT_LOCAL_DB}\n - {DEFAULT_REPORTS_DB}")
return
except Exception as e:
if temp_file.exists():
temp_file.unlink()
if attempt < max_retries:
print(f"\n[Attempt {attempt}/{max_retries}] Download encountered error ({e}). Retrying in 3s...", file=sys.stderr)
time.sleep(3)
else:
print(f"\nERROR: Failed to download from S3 after {max_retries} attempts: {e}", file=sys.stderr)
sys.exit(1)
def status_db():
"""Show status of remote and local databases."""
config = get_s3_config()
object_key = config["db_key"]
s3 = get_s3_client(config)
print("=" * 60)
print("Database Storage Status (Cloudflare R2 / S3)")
print("=" * 60)
print(f"Endpoint: {config['endpoint_url']}")
print(f"Bucket: {config['bucket_name']}")
print(f"DB Key: {object_key}")
print("-" * 60)
try:
head = s3.head_object(Bucket=config["bucket_name"], Key=object_key)
remote_size = head.get("ContentLength", 0)
last_modified = head.get("LastModified")
print(f"Remote DB: Exists ({remote_size / (1024*1024):.2f} MB)")
print(f" Last Modified: {last_modified}")
except ClientError as e:
print(f"Remote DB: NOT FOUND ({e})")
print("-" * 60)
if DEFAULT_LOCAL_DB.exists():
print(f"Local DB (Data): Exists ({DEFAULT_LOCAL_DB.stat().st_size / (1024*1024):.2f} MB) -> {DEFAULT_LOCAL_DB}")
else:
print(f"Local DB (Data): Missing -> {DEFAULT_LOCAL_DB}")
if DEFAULT_REPORTS_DB.exists():
print(f"Local DB (Reports): Exists ({DEFAULT_REPORTS_DB.stat().st_size / (1024*1024):.2f} MB) -> {DEFAULT_REPORTS_DB}")
else:
print(f"Local DB (Reports): Missing -> {DEFAULT_REPORTS_DB}")
print("=" * 60)
def main():
parser = argparse.ArgumentParser(description="Manage NBA DuckDB sync with Cloudflare R2 / S3.")
subparsers = parser.add_subparsers(dest="command", required=True)
# status
subparsers.add_parser("status", help="Check remote and local database status")
# upload
upload_parser = subparsers.add_parser("upload", help="Upload local DuckDB to S3/R2")
upload_parser.add_argument("--file", help="Path to local .duckdb file (default: data/DB/dbt_nba.duckdb)")
upload_parser.add_argument("--key", help="Remote object key (default: from env or dbt_nba.duckdb)")
# download
download_parser = subparsers.add_parser("download", help="Download DuckDB from S3/R2")
download_parser.add_argument("--force", action="store_true", help="Force download even if local file matches")
download_parser.add_argument("--key", help="Remote object key (default: from env or dbt_nba.duckdb)")
args = parser.parse_args()
if args.command == "status":
status_db()
elif args.command == "upload":
upload_db(file_path=args.file, key=args.key)
elif args.command == "download":
download_db(force=args.force, key=args.key)
if __name__ == "__main__":
main()