Spaces:
Sleeping
Sleeping
| """ | |
| Backup Module β 3-Layer Backup Strategy (FIXED) | |
| ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| Layer 1: Litestream β local file replica (every 1s, automatic) | |
| Layer 2: Python sqlite3.backup() β snapshot to /tmp (on-demand + scheduled) | |
| Layer 3: Upload snapshot to HF Bucket (persistent, survives restarts) | |
| Sources: | |
| - Python backup API: https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.backup | |
| - SQLite Backup API: https://sqlite.org/backup.html | |
| - Litestream: https://litestream.io/how-it-works/ | |
| - HF Hub API: https://huggingface.co/docs/huggingface_hub | |
| - SQLite VACUUM INTO: https://www.sqlite.org/lang_vacuum.html | |
| """ | |
| import sqlite3 | |
| import os | |
| import gzip | |
| import shutil | |
| import logging | |
| from datetime import datetime, timedelta | |
| from pathlib import Path | |
| from huggingface_hub import HfApi, hf_hub_download, create_repo, repo_exists | |
| from contextlib import contextmanager | |
| logger = logging.getLogger(__name__) | |
| DB_PATH = "/tmp/data/app.db" | |
| BACKUP_DIR = "/tmp/data/backups" | |
| SNAPSHOT_DIR = "/tmp/data/snapshots" | |
| RESTORE_DIR = "/tmp/data/restore" | |
| HF_TOKEN = os.environ.get("HF_TOKEN", "") | |
| HF_BUCKET_REPO = os.environ.get("HF_BUCKET_REPO", "") # "username/repo-name" | |
| # Ensure all directories exist | |
| os.makedirs(BACKUP_DIR, exist_ok=True) | |
| os.makedirs(SNAPSHOT_DIR, exist_ok=True) | |
| os.makedirs(RESTORE_DIR, exist_ok=True) | |
| os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # LAYER 2: Python sqlite3.backup() β On-Demand Snapshot | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def create_snapshot(label: str = "manual") -> dict: | |
| """ | |
| Create an atomic snapshot of the database using Python's backup API. | |
| This uses SQLite's Online Backup API under the hood β it's safe to call | |
| while the database is being written to. The backup is atomic and consistent. | |
| Source: https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.backup | |
| """ | |
| if not os.path.exists(DB_PATH): | |
| logger.warning(f"Database not found at {DB_PATH}, creating empty database first") | |
| # Create empty database with schema | |
| from app.database import init_db | |
| init_db() | |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| snapshot_name = f"app_{label}_{timestamp}.db" | |
| snapshot_path = os.path.join(SNAPSHOT_DIR, snapshot_name) | |
| try: | |
| # ββ Atomic backup using Python's built-in backup API ββ | |
| source = sqlite3.connect(DB_PATH) | |
| dest = sqlite3.connect(snapshot_path) | |
| source.backup(dest) | |
| dest.close() | |
| source.close() | |
| file_size = os.path.getsize(snapshot_path) | |
| # Log the backup | |
| _log_backup("snapshot", snapshot_path, file_size, "success") | |
| logger.info(f"β Snapshot created: {snapshot_name} ({file_size} bytes)") | |
| return { | |
| "status": "success", | |
| "file": snapshot_name, | |
| "path": snapshot_path, | |
| "size_bytes": file_size, | |
| "size_mb": round(file_size / (1024 * 1024), 2), | |
| "timestamp": timestamp, | |
| } | |
| except Exception as e: | |
| _log_backup("snapshot", snapshot_path, 0, "error", str(e)) | |
| logger.error(f"β Snapshot failed: {e}") | |
| return {"status": "error", "message": str(e)} | |
| def create_compressed_snapshot() -> dict: | |
| """ | |
| Create a gzip-compressed snapshot. | |
| SQLite databases compress very well (often 60-80% reduction). | |
| Source: https://litestream.io/alternatives/cron/ | |
| """ | |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| snapshot_name = f"app_compressed_{timestamp}.db.gz" | |
| gz_path = os.path.join(SNAPSHOT_DIR, snapshot_name) | |
| # Create uncompressed snapshot first (temp) | |
| result = create_snapshot(label="temp_for_compress") | |
| if result["status"] != "success": | |
| return result | |
| temp_snapshot_path = result["path"] | |
| original_size = result["size_bytes"] | |
| try: | |
| # Compress directly to final location | |
| with open(temp_snapshot_path, 'rb') as f_in: | |
| with gzip.open(gz_path, 'wb') as f_out: | |
| shutil.copyfileobj(f_in, f_out) | |
| # Remove temp uncompressed file | |
| if os.path.exists(temp_snapshot_path): | |
| os.remove(temp_snapshot_path) | |
| gz_size = os.path.getsize(gz_path) | |
| _log_backup("compressed_snapshot", gz_path, gz_size, "success") | |
| logger.info(f"β Compressed snapshot created: {snapshot_name} ({gz_size} bytes, {round((1 - gz_size/original_size)*100, 1)}% reduction)") | |
| return { | |
| "status": "success", | |
| "file": snapshot_name, | |
| "path": gz_path, # This is now the .gz file | |
| "size_bytes": original_size, | |
| "size_mb": round(original_size / (1024 * 1024), 2), | |
| "compressed_size_bytes": gz_size, | |
| "compressed_size_mb": round(gz_size / (1024 * 1024), 2), | |
| "compression_ratio": round((1 - gz_size / original_size) * 100, 1), | |
| } | |
| except Exception as e: | |
| # Clean up on failure | |
| if os.path.exists(temp_snapshot_path): | |
| os.remove(temp_snapshot_path) | |
| if os.path.exists(gz_path): | |
| os.remove(gz_path) | |
| _log_backup("compressed_snapshot", gz_path, 0, "error", str(e)) | |
| logger.error(f"β Compressed snapshot failed: {e}") | |
| return {"status": "error", "message": str(e)} | |
| def create_vacuum_snapshot() -> dict: | |
| """ | |
| Use VACUUM INTO for a fully compacted snapshot. | |
| This creates a snapshot from a single transaction β best for high-write scenarios. | |
| Also removes any free pages, giving you the smallest possible backup. | |
| Source: https://sqlite.org/lang_vacuum.html | |
| """ | |
| if not os.path.exists(DB_PATH): | |
| return {"status": "error", "message": "Database not found"} | |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| snapshot_name = f"app_vacuum_{timestamp}.db" | |
| snapshot_path = os.path.join(SNAPSHOT_DIR, snapshot_name) | |
| try: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute(f"VACUUM INTO '{snapshot_path}'") | |
| conn.close() | |
| file_size = os.path.getsize(snapshot_path) | |
| _log_backup("vacuum_snapshot", snapshot_path, file_size, "success") | |
| return { | |
| "status": "success", | |
| "file": snapshot_name, | |
| "path": snapshot_path, | |
| "size_bytes": file_size, | |
| "size_mb": round(file_size / (1024 * 1024), 2), | |
| } | |
| except Exception as e: | |
| return {"status": "error", "message": str(e)} | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # LAYER 3: HF Bucket Sync β Persistent Remote Storage | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def ensure_hf_repo_exists() -> dict: | |
| """ | |
| Ensure the HF dataset repository exists. | |
| Creates it if it doesn't exist. | |
| Returns status dict. | |
| """ | |
| if not HF_TOKEN: | |
| return {"status": "error", "message": "HF_TOKEN not set"} | |
| if not HF_BUCKET_REPO: | |
| return {"status": "error", "message": "HF_BUCKET_REPO not set"} | |
| try: | |
| api = HfApi(token=HF_TOKEN) | |
| # Check if repo exists | |
| try: | |
| if repo_exists(repo_id=HF_BUCKET_REPO, repo_type="dataset", token=HF_TOKEN): | |
| logger.info(f"β HF repo exists: {HF_BUCKET_REPO}") | |
| return {"status": "success", "message": "Repo exists"} | |
| except Exception: | |
| pass | |
| # Create repo if it doesn't exist | |
| logger.info(f"Creating HF dataset repo: {HF_BUCKET_REPO}") | |
| create_repo( | |
| repo_id=HF_BUCKET_REPO, | |
| repo_type="dataset", | |
| private=True, | |
| exist_ok=True, | |
| token=HF_TOKEN | |
| ) | |
| logger.info(f"β Created HF dataset repo: {HF_BUCKET_REPO}") | |
| return {"status": "success", "message": "Repo created"} | |
| except Exception as e: | |
| logger.error(f"β Failed to ensure HF repo exists: {e}") | |
| return {"status": "error", "message": str(e)} | |
| def upload_to_hf_bucket(local_path: str = None) -> dict: | |
| """ | |
| Upload a database snapshot to HuggingFace Bucket. | |
| If no local_path given, creates a fresh compressed snapshot first. | |
| Source: https://huggingface.co/docs/huggingface_hub/guides/upload | |
| """ | |
| # Check credentials | |
| if not HF_TOKEN or not HF_BUCKET_REPO: | |
| msg = f"HF_TOKEN={'set' if HF_TOKEN else 'NOT SET'}, HF_BUCKET_REPO={'set' if HF_BUCKET_REPO else 'NOT SET'}" | |
| logger.error(f"β HF credentials missing: {msg}") | |
| return {"status": "error", "message": f"Credentials not configured: {msg}"} | |
| # Ensure repo exists | |
| repo_status = ensure_hf_repo_exists() | |
| if repo_status["status"] != "success": | |
| return repo_status | |
| # Create snapshot if not provided | |
| if local_path is None: | |
| result = create_compressed_snapshot() | |
| if result["status"] != "success": | |
| return result | |
| local_path = result["path"] # Now correctly points to .gz file | |
| # Verify file exists | |
| if not os.path.exists(local_path): | |
| return {"status": "error", "message": f"Local file not found: {local_path}"} | |
| filename = os.path.basename(local_path) | |
| file_size = os.path.getsize(local_path) | |
| try: | |
| api = HfApi(token=HF_TOKEN) | |
| logger.info(f"π€ Uploading to HF Bucket: {filename} ({file_size} bytes)") | |
| # Upload latest (overwrites) | |
| api.upload_file( | |
| path_or_fileobj=local_path, | |
| path_in_repo="db-backups/latest.db.gz", | |
| repo_id=HF_BUCKET_REPO, | |
| repo_type="dataset", | |
| ) | |
| # Also keep a timestamped copy (rolling archive) | |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| api.upload_file( | |
| path_or_fileobj=local_path, | |
| path_in_repo=f"db-backups/archive/{filename}", | |
| repo_id=HF_BUCKET_REPO, | |
| repo_type="dataset", | |
| ) | |
| _log_backup("hf_bucket", f"db-backups/{filename}", file_size, "success") | |
| logger.info(f"β Uploaded to HF Bucket: {filename}") | |
| return {"status": "success", "remote_path": f"db-backups/{filename}", "size_bytes": file_size} | |
| except Exception as e: | |
| error_msg = str(e) | |
| _log_backup("hf_bucket", local_path, 0, "error", error_msg) | |
| logger.error(f"β HF Bucket upload failed: {error_msg}") | |
| return {"status": "error", "message": error_msg} | |
| def restore_from_hf_bucket() -> dict: | |
| """ | |
| Restore database from HF Bucket. | |
| Called on startup by entrypoint.sh if local DB doesn't exist. | |
| Source: https://huggingface.co/docs/huggingface_hub/guides/download | |
| """ | |
| if not HF_TOKEN or not HF_BUCKET_REPO: | |
| logger.warning("β οΈ HF credentials not set, skipping restore") | |
| return {"status": "skipped", "message": "No HF credentials configured"} | |
| if os.path.exists(DB_PATH) and os.path.getsize(DB_PATH) > 0: | |
| logger.info("β Database already exists, skipping restore") | |
| return {"status": "skipped", "message": "DB already exists"} | |
| # Ensure restore directory exists | |
| os.makedirs(RESTORE_DIR, exist_ok=True) | |
| os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) | |
| try: | |
| logger.info(f"π₯ Attempting to restore from HF Bucket: {HF_BUCKET_REPO}") | |
| # Download latest backup | |
| local_gz = hf_hub_download( | |
| repo_id=HF_BUCKET_REPO, | |
| filename="db-backups/latest.db.gz", | |
| repo_type="dataset", | |
| local_dir=RESTORE_DIR, | |
| token=HF_TOKEN, | |
| ) | |
| logger.info(f"β Downloaded backup: {local_gz}") | |
| # Decompress | |
| with gzip.open(local_gz, 'rb') as f_in: | |
| with open(DB_PATH, 'wb') as f_out: | |
| shutil.copyfileobj(f_in, f_out) | |
| # Verify integrity | |
| conn = sqlite3.connect(DB_PATH) | |
| result = conn.execute("PRAGMA integrity_check").fetchone() | |
| conn.close() | |
| if result[0] == "ok": | |
| logger.info("β Database restored and verified from HF Bucket") | |
| return {"status": "success", "integrity": "ok"} | |
| else: | |
| logger.error(f"β Restored database failed integrity: {result[0]}") | |
| os.remove(DB_PATH) # Remove corrupted restore | |
| return {"status": "error", "message": f"Integrity check failed: {result[0]}"} | |
| except Exception as e: | |
| error_msg = str(e) | |
| # Check if it's a "file not found" error (first run) | |
| if "404" in error_msg or "not found" in error_msg.lower(): | |
| logger.info("π¦ No backup found in HF Bucket (this is normal for first run)") | |
| return {"status": "not_found", "message": "No backup exists yet - this is normal for first run"} | |
| else: | |
| logger.error(f"β Restore from HF Bucket failed: {error_msg}") | |
| return {"status": "error", "message": error_msg} | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SCHEDULED BACKUP (runs inside FastAPI) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def scheduled_backup_task(): | |
| """ | |
| Background task that runs every 30 minutes. | |
| Creates snapshot + uploads to HF Bucket. | |
| """ | |
| import asyncio | |
| # Wait a bit before first backup (let app stabilize) | |
| await asyncio.sleep(60) | |
| while True: | |
| try: | |
| logger.info("β° Running scheduled backup...") | |
| result = upload_to_hf_bucket() | |
| if result["status"] == "success": | |
| logger.info("β Scheduled backup completed successfully") | |
| else: | |
| logger.warning(f"β οΈ Scheduled backup completed with issues: {result.get('message', 'unknown')}") | |
| cleanup_old_snapshots(max_age_hours=48) | |
| except Exception as e: | |
| logger.error(f"β Scheduled backup error: {e}") | |
| await asyncio.sleep(1800) # 30 minutes | |
| def cleanup_old_snapshots(max_age_hours: int = 48): | |
| """Remove local snapshots older than max_age_hours.""" | |
| cutoff = datetime.now() - timedelta(hours=max_age_hours) | |
| cleaned = 0 | |
| for f in Path(SNAPSHOT_DIR).glob("*.db*"): | |
| try: | |
| if datetime.fromtimestamp(f.stat().st_mtime) < cutoff: | |
| f.unlink() | |
| cleaned += 1 | |
| logger.info(f"ποΈ Removed old snapshot: {f.name}") | |
| except Exception as e: | |
| logger.warning(f"Failed to remove {f.name}: {e}") | |
| if cleaned > 0: | |
| logger.info(f"ποΈ Cleaned up {cleaned} old snapshots") | |
| def list_backups() -> dict: | |
| """List all available backups (local + remote info).""" | |
| local_files = [] | |
| for f in sorted(Path(SNAPSHOT_DIR).glob("*.db*"), key=lambda x: x.stat().st_mtime, reverse=True): | |
| try: | |
| local_files.append({ | |
| "name": f.name, | |
| "size_bytes": f.stat().st_size, | |
| "size_mb": round(f.stat().st_size / (1024 * 1024), 2), | |
| "modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat(), | |
| }) | |
| except Exception: | |
| pass | |
| return { | |
| "local_snapshots": local_files, | |
| "snapshot_dir": SNAPSHOT_DIR, | |
| "hf_bucket_repo": HF_BUCKET_REPO if HF_BUCKET_REPO else "Not configured", | |
| "hf_token_set": bool(HF_TOKEN), | |
| } | |
| # ββ Internal helper ββ | |
| def _log_backup(backup_type, file_path, file_size, status, error=None): | |
| """Log backup event to database.""" | |
| try: | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.execute( | |
| "INSERT INTO backup_log (backup_type, file_path, file_size, status, error_message) VALUES (?, ?, ?, ?, ?)", | |
| (backup_type, file_path, file_size, status, error) | |
| ) | |
| conn.commit() | |
| conn.close() | |
| except Exception as e: | |
| logger.warning(f"Failed to log backup event: {e}") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # DIAGNOSTICS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def diagnose_hf_setup() -> dict: | |
| """ | |
| Diagnose HF setup issues. | |
| Run this to check if everything is configured correctly. | |
| """ | |
| results = { | |
| "checks": [], | |
| "overall_status": "unknown" | |
| } | |
| # Check HF_TOKEN | |
| if HF_TOKEN: | |
| results["checks"].append({ | |
| "name": "HF_TOKEN", | |
| "status": "β SET", | |
| "value": f"{HF_TOKEN[:8]}...{HF_TOKEN[-4:]}" if len(HF_TOKEN) > 12 else "***" | |
| }) | |
| else: | |
| results["checks"].append({ | |
| "name": "HF_TOKEN", | |
| "status": "β NOT SET", | |
| "value": "Environment variable HF_TOKEN is missing" | |
| }) | |
| # Check HF_BUCKET_REPO | |
| if HF_BUCKET_REPO: | |
| results["checks"].append({ | |
| "name": "HF_BUCKET_REPO", | |
| "status": "β SET", | |
| "value": HF_BUCKET_REPO | |
| }) | |
| else: | |
| results["checks"].append({ | |
| "name": "HF_BUCKET_REPO", | |
| "status": "β NOT SET", | |
| "value": "Environment variable HF_BUCKET_REPO is missing" | |
| }) | |
| # Try to verify token by accessing API | |
| if HF_TOKEN: | |
| try: | |
| api = HfApi(token=HF_TOKEN) | |
| user_info = api.whoami() | |
| results["checks"].append({ | |
| "name": "Token Validation", | |
| "status": "β VALID", | |
| "value": f"Logged in as: {user_info.get('name', 'unknown')}" | |
| }) | |
| except Exception as e: | |
| results["checks"].append({ | |
| "name": "Token Validation", | |
| "status": "β INVALID", | |
| "value": str(e) | |
| }) | |
| # Check if repo exists/is accessible | |
| if HF_TOKEN and HF_BUCKET_REPO: | |
| try: | |
| exists = repo_exists(repo_id=HF_BUCKET_REPO, repo_type="dataset", token=HF_TOKEN) | |
| results["checks"].append({ | |
| "name": "Repository Check", | |
| "status": "β EXISTS" if exists else "β οΈ WILL BE CREATED", | |
| "value": HF_BUCKET_REPO | |
| }) | |
| except Exception as e: | |
| results["checks"].append({ | |
| "name": "Repository Check", | |
| "status": "β ERROR", | |
| "value": str(e) | |
| }) | |
| # Determine overall status | |
| failed = [c for c in results["checks"] if "β" in c["status"]] | |
| if not failed: | |
| results["overall_status"] = "β All checks passed" | |
| else: | |
| results["overall_status"] = f"β {len(failed)} check(s) failed" | |
| return results | |