Download services/sqlite_mirror_service.py from zlcy/study: direct link, hf CLI and curl.
- Browser
- Download file 14.6 kB
-
https://huggingface.co/spaces/zlcy/study/resolve/main/services/sqlite_mirror_service.py
- Command line
-
hf download hf://spaces/zlcy/study/services/sqlite_mirror_service.py
-
curl -L -o sqlite_mirror_service.py https://huggingface.co/spaces/zlcy/study/resolve/main/services/sqlite_mirror_service.py
14.6 kB
| from __future__ import annotations | |
| import os | |
| from pathlib import Path | |
| import shutil | |
| import sqlite3 | |
| import tempfile | |
| from threading import Event, RLock, Thread | |
| import time | |
| from typing import Iterable | |
| _SCHEMAS: dict[str, tuple[str, tuple[str, ...]]] = { | |
| "accounts": ( | |
| "CREATE TABLE IF NOT EXISTS accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, access_token VARCHAR(2048) NOT NULL UNIQUE, owner_id VARCHAR(255), shared INTEGER NOT NULL DEFAULT 0, status VARCHAR(64), data TEXT NOT NULL)", | |
| ("id", "access_token", "owner_id", "shared", "status", "data"), | |
| ), | |
| "auth_keys": ( | |
| "CREATE TABLE IF NOT EXISTS auth_keys (id INTEGER PRIMARY KEY AUTOINCREMENT, key_id VARCHAR(255) NOT NULL UNIQUE, data TEXT NOT NULL)", | |
| ("id", "key_id", "data"), | |
| ), | |
| "shop_state": ( | |
| 'CREATE TABLE IF NOT EXISTS shop_state ("key" VARCHAR(64) PRIMARY KEY, data TEXT NOT NULL)', | |
| ("key", "data"), | |
| ), | |
| "redeem_codes": ( | |
| "CREATE TABLE IF NOT EXISTS redeem_codes (id INTEGER PRIMARY KEY AUTOINCREMENT, code_id VARCHAR(64) NOT NULL UNIQUE, code_hash VARCHAR(128) NOT NULL UNIQUE, batch_id VARCHAR(64), redeemed_by VARCHAR(255), status VARCHAR(32) NOT NULL, data TEXT NOT NULL)", | |
| ("id", "code_id", "code_hash", "batch_id", "redeemed_by", "status", "data"), | |
| ), | |
| "shop_ledger": ( | |
| "CREATE TABLE IF NOT EXISTS shop_ledger (id INTEGER PRIMARY KEY AUTOINCREMENT, ledger_id VARCHAR(64) NOT NULL UNIQUE, created_at VARCHAR(64), user_id VARCHAR(255), data TEXT NOT NULL)", | |
| ("id", "ledger_id", "created_at", "user_id", "data"), | |
| ), | |
| # 生图用量流水(data/credit_ledger.db)。它不在上面那几个库里,但同样跑在这套 | |
| # 镜像机制下;登记 schema 是为了数据库损坏时能被尽力抢救,而不是只能丢掉。 | |
| "credit_usage": ( | |
| "CREATE TABLE IF NOT EXISTS credit_usage (id INTEGER PRIMARY KEY AUTOINCREMENT, created_ts REAL NOT NULL, day TEXT NOT NULL, identity_id TEXT NOT NULL, role TEXT NOT NULL, model TEXT NOT NULL, status TEXT NOT NULL, image_count INTEGER NOT NULL DEFAULT 0, credits REAL NOT NULL DEFAULT 0, unit_price REAL NOT NULL DEFAULT 0, task_id TEXT NOT NULL DEFAULT '')", | |
| ("id", "created_ts", "day", "identity_id", "role", "model", "status", "image_count", "credits", "unit_price", "task_id"), | |
| ), | |
| } | |
| def _enabled() -> bool: | |
| return os.getenv("CHATGPT2API_SQLITE_BUCKET_SAFE_MODE", "true").strip().lower() not in {"0", "false", "no", "off"} | |
| def _integrity_ok(path: Path) -> bool: | |
| if not path.is_file() or path.stat().st_size == 0: | |
| return False | |
| connection: sqlite3.Connection | None = None | |
| try: | |
| connection = sqlite3.connect(f"file:{path.as_posix()}?mode=ro", uri=True, timeout=10) | |
| row = connection.execute("PRAGMA integrity_check").fetchone() | |
| return bool(row and str(row[0]).lower() == "ok") | |
| except sqlite3.Error: | |
| return False | |
| finally: | |
| if connection is not None: | |
| connection.close() | |
| def _copy_file(source: Path, destination: Path) -> None: | |
| destination.parent.mkdir(parents=True, exist_ok=True) | |
| temporary = destination.with_name(f".{destination.name}.copying-{os.getpid()}-{time.time_ns()}") | |
| try: | |
| shutil.copyfile(source, temporary) | |
| os.replace(temporary, destination) | |
| finally: | |
| temporary.unlink(missing_ok=True) | |
| class SQLiteMirrorService: | |
| """Run SQLite locally and persist only verified, immutable snapshots. | |
| Hugging Face Storage Buckets are mounted through a network filesystem. A | |
| live SQLite database performs page-level random writes and lock/journal | |
| coordination that such mounts cannot reliably preserve. This service keeps | |
| the live files on the container's local disk and treats the mounted bucket | |
| as snapshot storage only. | |
| """ | |
| def __init__(self) -> None: | |
| self._lock = RLock() | |
| self._runtime_dirs: dict[str, Path] = {} | |
| self._databases: dict[str, tuple[Path, Path]] = {} | |
| self._snapshot_signatures: dict[str, tuple[int, int]] = {} | |
| self._stop_event: Event | None = None | |
| self._thread: Thread | None = None | |
| def should_mirror(data_dir: Path) -> bool: | |
| if not _enabled(): | |
| return False | |
| persist_dir = Path(os.getenv("CHATGPT2API_PERSIST_DIR", "").strip() or "/app/persist") | |
| try: | |
| return persist_dir.exists() and data_dir.resolve(strict=False).is_relative_to(persist_dir.resolve(strict=False)) | |
| except (OSError, ValueError): | |
| return False | |
| def runtime_data_dir(self, data_dir: Path) -> Path: | |
| if not self.should_mirror(data_dir): | |
| return data_dir | |
| key = str(data_dir.resolve(strict=False)) | |
| with self._lock: | |
| existing = self._runtime_dirs.get(key) | |
| if existing is not None: | |
| return existing | |
| configured = os.getenv("CHATGPT2API_SQLITE_RUNTIME_DIR", "").strip() | |
| runtime = Path(configured) if configured else Path(tempfile.gettempdir()) / "chatgpt2api-sqlite" | |
| runtime.mkdir(parents=True, exist_ok=True) | |
| marker = data_dir / ".local_user_database_split_migrated" | |
| if marker.is_file(): | |
| _copy_file(marker, runtime / marker.name) | |
| for filename in ("accounts.db", "users.db"): | |
| self._prepare_database(data_dir, runtime, filename) | |
| self._runtime_dirs[key] = runtime | |
| return runtime | |
| def runtime_file(self, data_dir: Path, filename: str) -> Path: | |
| """Return a safe runtime path and register extra SQLite databases.""" | |
| runtime = self.runtime_data_dir(data_dir) | |
| if runtime == data_dir: | |
| return data_dir / filename | |
| target = runtime / filename | |
| if filename.endswith(".db"): | |
| with self._lock: | |
| if filename not in self._databases: | |
| self._prepare_database(data_dir, runtime, filename) | |
| return target | |
| source = data_dir / filename | |
| if source.is_file() and not target.exists(): | |
| _copy_file(source, target) | |
| return target | |
| def _snapshot_candidates(self, data_dir: Path, filename: str) -> Iterable[Path]: | |
| snapshot_dir = data_dir / "sqlite-snapshots" / filename.removesuffix(".db") | |
| if snapshot_dir.is_dir(): | |
| yield from sorted(snapshot_dir.glob("*.db"), reverse=True) | |
| legacy = data_dir / filename | |
| if legacy.is_file(): | |
| yield legacy | |
| def _prepare_database(self, data_dir: Path, runtime: Path, filename: str) -> None: | |
| live = runtime / filename | |
| if _integrity_ok(live): | |
| self._databases[filename] = (live, data_dir) | |
| return | |
| live.unlink(missing_ok=True) | |
| chosen: Path | None = None | |
| for candidate in self._snapshot_candidates(data_dir, filename): | |
| if _integrity_ok(candidate): | |
| _copy_file(candidate, live) | |
| chosen = candidate | |
| break | |
| if chosen is not None: | |
| print(f"[sqlite-mirror] restored {filename} from {chosen}") | |
| else: | |
| legacy = data_dir / filename | |
| if legacy.is_file(): | |
| chosen = self._salvage_database(legacy, live, data_dir) | |
| if chosen is None: | |
| live.parent.mkdir(parents=True, exist_ok=True) | |
| self._databases[filename] = (live, data_dir) | |
| def _salvage_database(self, source: Path, target: Path, data_dir: Path) -> Path | None: | |
| timestamp = time.strftime("%Y%m%d-%H%M%S", time.gmtime()) | |
| quarantine = data_dir / "sqlite-corrupt" / f"{source.stem}-{timestamp}.db" | |
| try: | |
| _copy_file(source, quarantine) | |
| except OSError as exc: | |
| print(f"[sqlite-mirror] failed to preserve corrupt {source.name}: {exc}") | |
| target.unlink(missing_ok=True) | |
| recovered: dict[str, int] = {} | |
| source_connection: sqlite3.Connection | None = None | |
| target_connection: sqlite3.Connection | None = None | |
| try: | |
| source_connection = sqlite3.connect(f"file:{source.as_posix()}?mode=ro", uri=True, timeout=10) | |
| target_connection = sqlite3.connect(str(target), timeout=30) | |
| target_connection.execute("PRAGMA journal_mode=DELETE") | |
| target_connection.execute("PRAGMA synchronous=FULL") | |
| table_names = {str(row[0]) for row in source_connection.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()} | |
| for table_name, (schema, columns) in _SCHEMAS.items(): | |
| if table_name not in table_names: | |
| continue | |
| target_connection.execute(schema) | |
| quoted_columns = ",".join(f'"{column}"' for column in columns) | |
| placeholders = ",".join("?" for _ in columns) | |
| count = 0 | |
| try: | |
| cursor = source_connection.execute(f'SELECT {quoted_columns} FROM "{table_name}" ORDER BY rowid') | |
| except sqlite3.Error as exc: | |
| recovered[table_name] = 0 | |
| print(f"[sqlite-mirror] unable to start recovery for {table_name}: {exc}") | |
| continue | |
| while True: | |
| try: | |
| row = cursor.fetchone() | |
| except sqlite3.Error as exc: | |
| print(f"[sqlite-mirror] partial recovery for {table_name}: {exc}") | |
| break | |
| if row is None: | |
| break | |
| try: | |
| target_connection.execute( | |
| f'INSERT OR IGNORE INTO "{table_name}" ({quoted_columns}) VALUES ({placeholders})', | |
| row, | |
| ) | |
| count += 1 | |
| except sqlite3.Error: | |
| continue | |
| recovered[table_name] = count | |
| target_connection.commit() | |
| row = target_connection.execute("PRAGMA integrity_check").fetchone() | |
| if not row or str(row[0]).lower() != "ok": | |
| raise sqlite3.DatabaseError("recovered database failed integrity_check") | |
| print(f"[sqlite-mirror] salvaged corrupt {source.name}: {recovered}; original={quarantine}") | |
| return quarantine | |
| except sqlite3.Error as exc: | |
| target.unlink(missing_ok=True) | |
| print(f"[sqlite-mirror] automatic salvage failed for {source.name}: {exc}; original={quarantine}") | |
| return None | |
| finally: | |
| if target_connection is not None: | |
| target_connection.close() | |
| if source_connection is not None: | |
| source_connection.close() | |
| def _snapshot_database(self, filename: str, live: Path, data_dir: Path) -> Path | None: | |
| if not live.is_file(): | |
| return None | |
| snapshot_dir = data_dir / "sqlite-snapshots" / filename.removesuffix(".db") | |
| snapshot_dir.mkdir(parents=True, exist_ok=True) | |
| timestamp = time.strftime("%Y%m%d-%H%M%S", time.gmtime()) | |
| local_copy = live.parent / f".{filename}.snapshot-{os.getpid()}-{time.time_ns()}" | |
| source_connection: sqlite3.Connection | None = None | |
| target_connection: sqlite3.Connection | None = None | |
| try: | |
| source_connection = sqlite3.connect(str(live), timeout=30) | |
| target_connection = sqlite3.connect(str(local_copy), timeout=30) | |
| source_connection.backup(target_connection) | |
| target_connection.close() | |
| target_connection = None | |
| if not _integrity_ok(local_copy): | |
| raise sqlite3.DatabaseError("snapshot integrity_check failed") | |
| destination = snapshot_dir / f"{timestamp}-{time.time_ns()}.db" | |
| _copy_file(local_copy, destination) | |
| if not _integrity_ok(destination): | |
| destination.unlink(missing_ok=True) | |
| raise sqlite3.DatabaseError("uploaded snapshot integrity_check failed") | |
| snapshots = sorted(snapshot_dir.glob("*.db"), reverse=True) | |
| keep = max(2, int(os.getenv("CHATGPT2API_SQLITE_SNAPSHOT_KEEP", "5"))) | |
| for stale in snapshots[keep:]: | |
| stale.unlink(missing_ok=True) | |
| return destination | |
| except (OSError, sqlite3.Error) as exc: | |
| print(f"[sqlite-mirror] snapshot failed for {filename}: {exc}") | |
| return None | |
| finally: | |
| if target_connection is not None: | |
| target_connection.close() | |
| if source_connection is not None: | |
| source_connection.close() | |
| local_copy.unlink(missing_ok=True) | |
| def snapshot_all(self, *, force: bool = False) -> list[str]: | |
| completed: list[str] = [] | |
| with self._lock: | |
| databases = list(self._databases.items()) | |
| for filename, (live, data_dir) in databases: | |
| try: | |
| stat = live.stat() | |
| signature = (stat.st_mtime_ns, stat.st_size) | |
| except OSError: | |
| continue | |
| if not force and self._snapshot_signatures.get(filename) == signature: | |
| continue | |
| if self._snapshot_database(filename, live, data_dir) is not None: | |
| completed.append(filename) | |
| self._snapshot_signatures[filename] = signature | |
| if completed: | |
| print(f"[sqlite-mirror] verified snapshots saved: {', '.join(completed)}") | |
| return completed | |
| def start(self) -> None: | |
| with self._lock: | |
| if self._thread is not None or not self._databases: | |
| return | |
| self.snapshot_all(force=True) | |
| stop_event = Event() | |
| interval = max(10, int(os.getenv("CHATGPT2API_SQLITE_SNAPSHOT_INTERVAL_SECS", "30"))) | |
| def worker() -> None: | |
| while not stop_event.wait(interval): | |
| self.snapshot_all() | |
| self._stop_event = stop_event | |
| self._thread = Thread(target=worker, name="sqlite-bucket-snapshot", daemon=True) | |
| self._thread.start() | |
| def stop(self) -> None: | |
| with self._lock: | |
| stop_event = self._stop_event | |
| thread = self._thread | |
| self._stop_event = None | |
| self._thread = None | |
| if stop_event is None: | |
| return | |
| stop_event.set() | |
| if thread is not None: | |
| thread.join(timeout=2) | |
| self.snapshot_all(force=True) | |
| sqlite_mirror_service = SQLiteMirrorService() | |