import os import sys import time import sqlite3 import threading import logging from collections import OrderedDict from backend.config import Config logger = logging.getLogger("db_manager") logger.setLevel(logging.INFO) if not logger.handlers: handler = logging.StreamHandler(sys.stdout) handler.setFormatter(logging.Formatter( "[%(asctime)s] %(levelname)s [db_manager] %(message)s", datefmt="%Y-%m-%d %H:%M:%S" )) logger.addHandler(handler) DATABASE_URL = os.environ.get("DATABASE_URL", "") USE_LOCAL_SQLITE = os.environ.get("USE_LOCAL_SQLITE", "false").lower() == "true" if USE_LOCAL_SQLITE: DATABASE_URL = "" USER_DB_PATH = Config.USER_DB_PATH # Retry & circuit breaker settings MAX_RETRIES = 3 RETRY_BASE_DELAY = 0.5 # seconds CIRCUIT_BREAKER_THRESHOLD = 5 # consecutive failures before opening circuit CIRCUIT_BREAKER_RESET = 30 # seconds before retrying after circuit opens # Circuit Breaker State _lock = threading.Lock() _failure_count = 0 _circuit_open_until = 0.0 # timestamp when circuit should try again _last_mode = "unknown" # track for logging def _is_circuit_open(): """Check if the circuit breaker is open (cloud DB unavailable).""" global _failure_count, _circuit_open_until with _lock: if _failure_count >= CIRCUIT_BREAKER_THRESHOLD: if time.time() < _circuit_open_until: return True return False return False def _record_success(): """Record a successful cloud DB connection — reset circuit breaker.""" global _failure_count, _circuit_open_until, _last_mode with _lock: if _failure_count > 0: logger.info("✔ Supabase connection restored. Resetting circuit breaker.") _failure_count = 0 _circuit_open_until = 0.0 _last_mode = "postgres" def _record_failure(): """Record a failed cloud DB connection — increment circuit breaker.""" global _failure_count, _circuit_open_until, _last_mode with _lock: _failure_count += 1 if _failure_count >= CIRCUIT_BREAKER_THRESHOLD: _circuit_open_until = time.time() + CIRCUIT_BREAKER_RESET if _last_mode != "sqlite_fallback": logger.warning( f"⚡ Circuit breaker OPEN after {_failure_count} failures. " f"Falling back to SQLite for {CIRCUIT_BREAKER_RESET}s." ) _last_mode = "sqlite_fallback" # PostgreSQL wrappers class PgCursorWrapper: def __init__(self, pg_cursor, lastrowid=None, conn_wrapper=None): self._cur = pg_cursor self._lastrowid = lastrowid self._conn_wrapper = conn_wrapper def execute(self, sql, parameters=None): if self._conn_wrapper: sql = self._conn_wrapper._translate_sql(sql) if parameters: self._cur.execute(sql, parameters) else: self._cur.execute(sql) return self def fetchone(self): return self._cur.fetchone() def fetchall(self): return self._cur.fetchall() @property def lastrowid(self): return self._lastrowid @property def description(self): return self._cur.description class PgConnectionWrapper: def __init__(self, pg_conn, pool=None): self._conn = pg_conn self._pool = pool @property def row_factory(self): return None @row_factory.setter def row_factory(self, val): pass def cursor(self): import psycopg2.extras cursor = self._conn.cursor(cursor_factory=psycopg2.extras.DictCursor) return PgCursorWrapper(cursor, conn_wrapper=self) def execute(self, sql, parameters=None): sql_pg = self._translate_sql(sql) is_insert = sql_pg.strip().upper().startswith("INSERT") if is_insert and "RETURNING" not in sql_pg.upper(): if "USER_SETTINGS" in sql_pg.upper(): sql_pg += " RETURNING user_id" else: sql_pg += " RETURNING id" import psycopg2.extras cursor = self._conn.cursor(cursor_factory=psycopg2.extras.DictCursor) sql_upper = sql_pg.upper() start_time = time.time() try: if parameters: cursor.execute(sql_pg, parameters) elif "CREATE TABLE" in sql_upper or "ALTER TABLE" in sql_upper: try: cursor.execute("SAVEPOINT sp_ddl") cursor.execute(sql_pg) cursor.execute("RELEASE SAVEPOINT sp_ddl") except Exception as e: cursor.execute("ROLLBACK TO SAVEPOINT sp_ddl") cursor.execute("RELEASE SAVEPOINT sp_ddl") raise else: cursor.execute(sql_pg) finally: try: from backend.core.profiler import record_db_time record_db_time(time.time() - start_time) except Exception: pass lastrowid = None if is_insert: try: row = cursor.fetchone() if row: lastrowid = row[0] except Exception: pass return PgCursorWrapper(cursor, lastrowid) def commit(self): start_time = time.time() try: self._conn.commit() finally: try: from backend.core.profiler import record_db_time record_db_time(time.time() - start_time) except Exception: pass def close(self): if self._pool is not None: try: try: self._conn.rollback() except Exception: pass self._pool.putconn(self._conn) except Exception as e: logger.error(f"Error returning connection to pool: {e}") try: self._conn.close() except Exception: pass else: self._conn.close() @staticmethod def _translate_sql(sql): import re if re.search(r"INSERT\s+OR\s+IGNORE\s+INTO\s+bookshelf", sql, re.IGNORECASE): sql = re.sub(r"INSERT\s+OR\s+IGNORE\s+INTO", "INSERT INTO", sql, flags=re.IGNORECASE) if "ON CONFLICT" not in sql.upper(): sql += " ON CONFLICT (user_id, book_id) DO NOTHING" elif re.search(r"INSERT\s+OR\s+REPLACE\s+INTO\s+bookshelf", sql, re.IGNORECASE): sql = re.sub(r"INSERT\s+OR\s+REPLACE\s+INTO", "INSERT INTO", sql, flags=re.IGNORECASE) if "ON CONFLICT" not in sql.upper(): sql += " ON CONFLICT (user_id, book_id) DO UPDATE SET title = EXCLUDED.title, author = EXCLUDED.author, cover = EXCLUDED.cover, url = EXCLUDED.url" elif re.search(r"INSERT\s+OR\s+REPLACE\s+INTO\s+reading_history", sql, re.IGNORECASE): sql = re.sub(r"INSERT\s+OR\s+REPLACE\s+INTO", "INSERT INTO", sql, flags=re.IGNORECASE) if "ON CONFLICT" not in sql.upper(): if "URL" in sql.upper(): sql += " ON CONFLICT (user_id, book_id) DO UPDATE SET title = EXCLUDED.title, author = EXCLUDED.author, cover = EXCLUDED.cover, last_chapter = EXCLUDED.last_chapter, read_date = EXCLUDED.read_date, url = EXCLUDED.url" else: sql += " ON CONFLICT (user_id, book_id) DO UPDATE SET title = EXCLUDED.title, author = EXCLUDED.author, cover = EXCLUDED.cover, last_chapter = EXCLUDED.last_chapter, read_date = EXCLUDED.read_date, timestamp = EXCLUDED.timestamp" elif re.search(r"INSERT\s+OR\s+REPLACE\s+INTO\s+friendships", sql, re.IGNORECASE): sql = re.sub(r"INSERT\s+OR\s+REPLACE\s+INTO", "INSERT INTO", sql, flags=re.IGNORECASE) if "ON CONFLICT" not in sql.upper(): sql += " ON CONFLICT (user_id, friend_id) DO UPDATE SET status = EXCLUDED.status" sql_upper = sql.upper() if "CREATE TABLE" in sql_upper: sql = sql.replace("CREATE TABLE ", "CREATE TABLE IF NOT EXISTS ") sql = sql.replace("CREATE TABLE IF NOT EXISTS IF NOT EXISTS", "CREATE TABLE IF NOT EXISTS") sql = sql.replace("INTEGER PRIMARY KEY AUTOINCREMENT", "SERIAL PRIMARY KEY") sql = sql.replace("DATETIME DEFAULT CURRENT_TIMESTAMP", "TIMESTAMP DEFAULT CURRENT_TIMESTAMP") sql = sql.replace("DATETIME", "TIMESTAMP") # Translate LIKE to ILIKE for case-insensitive matching on PostgreSQL if " LIKE " in sql_upper: sql = re.sub(r"\bLIKE\b", "ILIKE", sql, flags=re.IGNORECASE) sql = sql.replace('?', '%s') return sql _pg_pool = None _pool_lock = threading.Lock() def _get_pg_pool(): global _pg_pool if not DATABASE_URL: return None if _pg_pool is not None: return _pg_pool with _pool_lock: if _pg_pool is None: try: from psycopg2.pool import ThreadedConnectionPool _pg_pool = ThreadedConnectionPool(2, 20, DATABASE_URL, connect_timeout=5) logger.info("✔ Threaded Supabase connection pool initialized successfully.") except Exception as e: logger.error(f"Failed to initialize Threaded connection pool: {e}") _pg_pool = None return _pg_pool def _try_postgres_connect(): pool = _get_pg_pool() if pool is None: raise Exception("Supabase Connection Pool not initialized.") conn = None for i in range(3): try: conn = pool.getconn() with conn.cursor() as cur: cur.execute("SELECT 1") break except Exception as e: logger.warning(f"Got unhealthy connection from pool: {e}. Closing and retrying...") if conn: try: pool.putconn(conn, close=True) except Exception: pass conn = None if conn is None: raise Exception("Failed to obtain a healthy connection from the pool.") return PgConnectionWrapper(conn, pool) class ProfiledSqliteConnection(sqlite3.Connection): def execute(self, sql, *args): start_time = time.time() try: return super().execute(sql, *args) finally: try: from backend.core.profiler import record_db_time record_db_time(time.time() - start_time) except Exception: pass def _get_sqlite_conn(): conn = sqlite3.connect(USER_DB_PATH, timeout=10, factory=ProfiledSqliteConnection) conn.execute("PRAGMA synchronous=NORMAL;") conn.execute("PRAGMA synchronous=NORMAL;") conn.execute("PRAGMA cache_size=-64000;") conn.execute("PRAGMA temp_store=MEMORY;") conn.row_factory = sqlite3.Row return conn def get_user_db_conn(): """Get user database connection with automatic failover.""" if not DATABASE_URL: return _get_sqlite_conn() if _is_circuit_open(): return _get_sqlite_conn() last_error = None for attempt in range(MAX_RETRIES): try: conn = _try_postgres_connect() _record_success() return conn except Exception as e: last_error = e delay = RETRY_BASE_DELAY * (2 ** attempt) if attempt < MAX_RETRIES - 1: logger.warning( f"Supabase connection attempt {attempt + 1}/{MAX_RETRIES} failed: " f"{e}. Retrying in {delay:.1f}s..." ) time.sleep(delay) _record_failure() logger.error(f"Supabase unreachable: {last_error}. Using SQLite fallback.") return _get_sqlite_conn() def health_check(): status = { "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "database": { "primary": "supabase_postgres" if DATABASE_URL else "sqlite_only", "fallback": "sqlite_local", "circuit_breaker": { "state": "open" if _is_circuit_open() else "closed", "failure_count": _failure_count, "threshold": CIRCUIT_BREAKER_THRESHOLD, }, }, "status": "healthy", } if DATABASE_URL and not _is_circuit_open(): try: conn = _try_postgres_connect() conn.close() # Returns to pool via PgConnectionWrapper status["database"]["primary_reachable"] = True except Exception as e: status["database"]["primary_reachable"] = False status["database"]["primary_error"] = str(e) status["status"] = "degraded" elif _is_circuit_open(): status["database"]["primary_reachable"] = False status["status"] = "degraded" # Use a lightweight check without opening a new connection try: if os.path.exists(USER_DB_PATH) and os.path.getsize(USER_DB_PATH) > 0: status["database"]["fallback_reachable"] = True else: status["database"]["fallback_reachable"] = False status["status"] = "degraded" except Exception as e: status["database"]["fallback_reachable"] = False status["database"]["fallback_error"] = str(e) status["status"] = "unhealthy" return status def _init_db_schema_for_conn(conn): is_postgres = (type(conn).__name__ == "PgConnectionWrapper") conn.execute(""" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE, password_hash TEXT, email TEXT, phone TEXT, google_id TEXT, vip_status INTEGER DEFAULT 0, vip_plan TEXT, vip_expiry DATETIME, email_verified INTEGER DEFAULT 0, require_password_change INTEGER DEFAULT 0, display_name TEXT, birthday TEXT, gender TEXT, bio TEXT, avatar_frame TEXT DEFAULT 'default', avatar TEXT, two_factor INTEGER DEFAULT 0, api_balance REAL DEFAULT 0.0, user_code TEXT UNIQUE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) """) # Run database migrations for users columns if needed if is_postgres: # Postgres columns check cursor = conn.execute("SELECT column_name FROM information_schema.columns WHERE table_name = 'users'") columns = [row[0] for row in cursor.fetchall()] else: # SQLite columns check cursor = conn.execute("PRAGMA table_info(users)") columns = [row[1] for row in cursor.fetchall()] if "email_verified" not in columns: conn.execute("ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0") if "require_password_change" not in columns: conn.execute("ALTER TABLE users ADD COLUMN require_password_change INTEGER DEFAULT 0") if "display_name" not in columns: conn.execute("ALTER TABLE users ADD COLUMN display_name TEXT") if "birthday" not in columns: conn.execute("ALTER TABLE users ADD COLUMN birthday TEXT") if "gender" not in columns: conn.execute("ALTER TABLE users ADD COLUMN gender TEXT") if "bio" not in columns: conn.execute("ALTER TABLE users ADD COLUMN bio TEXT") if "avatar_frame" not in columns: conn.execute("ALTER TABLE users ADD COLUMN avatar_frame TEXT DEFAULT 'default'") if "avatar" not in columns: conn.execute("ALTER TABLE users ADD COLUMN avatar TEXT") if "two_factor" not in columns: conn.execute("ALTER TABLE users ADD COLUMN two_factor INTEGER DEFAULT 0") if "api_balance" not in columns: conn.execute("ALTER TABLE users ADD COLUMN api_balance REAL DEFAULT 0.0") if "user_code" not in columns: conn.execute("ALTER TABLE users ADD COLUMN user_code TEXT") # Backfill existing users with random 7-digit codes import random, string existing_users = conn.execute("SELECT id FROM users WHERE user_code IS NULL").fetchall() for u in existing_users: while True: code = ''.join(random.choices(string.digits, k=7)) clash = conn.execute("SELECT 1 FROM users WHERE user_code = ?", (code,)).fetchone() if not clash: conn.execute("UPDATE users SET user_code = ? WHERE id = ?", (code, u['id'])) break conn.execute(""" CREATE TABLE IF NOT EXISTS bookshelf ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, book_id INTEGER, title TEXT, cover TEXT, author TEXT, url TEXT, added_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, book_id) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS reading_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, book_id INTEGER, title TEXT, cover TEXT, author TEXT, last_chapter TEXT, read_date TEXT, url TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, book_id) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS payments ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, order_id TEXT UNIQUE NOT NULL, plan TEXT NOT NULL, amount INTEGER NOT NULL, status TEXT DEFAULT 'pending', payment_method TEXT DEFAULT 'vietqr', note TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, completed_at DATETIME, FOREIGN KEY (user_id) REFERENCES users(id) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS password_reset_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, token TEXT UNIQUE NOT NULL, expires_at DATETIME NOT NULL, used INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS refresh_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, token TEXT UNIQUE NOT NULL, expires_at DATETIME NOT NULL, revoked INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS api_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, api_key TEXT UNIQUE NOT NULL, name TEXT DEFAULT 'Default Key', status TEXT DEFAULT 'active', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_used_at DATETIME, FOREIGN KEY (user_id) REFERENCES users(id) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS api_usage ( id INTEGER PRIMARY KEY AUTOINCREMENT, api_key TEXT NOT NULL, model TEXT NOT NULL, tokens INTEGER DEFAULT 0, cost REAL DEFAULT 0.0, status_code INTEGER, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (api_key) REFERENCES api_keys(api_key) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS translation_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, original_text TEXT NOT NULL, translated_text TEXT NOT NULL, mode TEXT DEFAULT 'fast', characters INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS vocabulary ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, original_text TEXT NOT NULL, pinyin_or_hanviet TEXT, translation TEXT NOT NULL, context_sentence TEXT, notes TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS user_settings ( user_id INTEGER PRIMARY KEY, theme TEXT DEFAULT 'dark', default_language TEXT DEFAULT 'vi', auto_read INTEGER DEFAULT 0, font_size INTEGER DEFAULT 14, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS usage_tracking ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, source TEXT NOT NULL, action TEXT NOT NULL, duration INTEGER DEFAULT 0, mode TEXT DEFAULT 'online', timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS login_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, ip_address TEXT, user_agent TEXT, os TEXT, browser TEXT, device_type TEXT, token TEXT, status TEXT DEFAULT 'active', login_time DATETIME DEFAULT CURRENT_TIMESTAMP, last_active DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS friendships ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, friend_id INTEGER NOT NULL, status TEXT DEFAULT 'pending', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (friend_id) REFERENCES users(id), UNIQUE(user_id, friend_id) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS direct_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, sender_id INTEGER NOT NULL, receiver_id INTEGER NOT NULL, message TEXT NOT NULL, is_read INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (sender_id) REFERENCES users(id), FOREIGN KEY (receiver_id) REFERENCES users(id) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS personal_notifications ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, sender_id INTEGER, type TEXT NOT NULL, message TEXT NOT NULL, related_id INTEGER, is_read INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (sender_id) REFERENCES users(id) ) """) for table in ["bookshelf", "reading_history"]: try: conn.execute(f"ALTER TABLE {table} ADD COLUMN url TEXT") except Exception: pass new_user_cols = [ ("email", "TEXT"), ("phone", "TEXT"), ("google_id", "TEXT"), ("vip_plan", "TEXT"), ("vip_expiry", "DATETIME"), ("api_balance", "REAL DEFAULT 0.0"), ] for col_name, col_type in new_user_cols: try: conn.execute(f"ALTER TABLE users ADD COLUMN {col_name} {col_type}") except Exception: pass conn.execute("CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_user_code ON users(user_code)") conn.execute("CREATE INDEX IF NOT EXISTS idx_reading_history_user_id ON reading_history(user_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_reading_history_url ON reading_history(url)") conn.execute("CREATE INDEX IF NOT EXISTS idx_bookshelf_user_id ON bookshelf(user_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_translation_history_user_id ON translation_history(user_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_vocabulary_user_id ON vocabulary(user_id)") conn.execute(""" CREATE TABLE IF NOT EXISTS sects ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, slogan TEXT, announcement TEXT, badge TEXT, leader_id INTEGER, level INTEGER DEFAULT 1, contribution INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS sect_members ( id INTEGER PRIMARY KEY AUTOINCREMENT, sect_id INTEGER, user_id INTEGER UNIQUE, role TEXT DEFAULT 'member', contribution INTEGER DEFAULT 0, joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (sect_id) REFERENCES sects(id), FOREIGN KEY (user_id) REFERENCES users(id) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS sect_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, sect_id INTEGER, sender_id INTEGER, message TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (sect_id) REFERENCES sects(id), FOREIGN KEY (sender_id) REFERENCES users(id) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS sect_join_requests ( id INTEGER PRIMARY KEY AUTOINCREMENT, sect_id INTEGER, user_id INTEGER, status TEXT DEFAULT 'pending', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (sect_id) REFERENCES sects(id), FOREIGN KEY (user_id) REFERENCES users(id), UNIQUE(sect_id, user_id) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS sect_books ( id INTEGER PRIMARY KEY AUTOINCREMENT, sect_id INTEGER, book_id INTEGER, added_by INTEGER, added_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (sect_id) REFERENCES sects(id), FOREIGN KEY (added_by) REFERENCES users(id), UNIQUE(sect_id, book_id) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS sect_chat_groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, sect_id INTEGER, name TEXT, creator_id INTEGER, members_csv TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (sect_id) REFERENCES sects(id), FOREIGN KEY (creator_id) REFERENCES users(id) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS book_comments ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id INTEGER NOT NULL, user_id INTEGER, is_anonymous INTEGER DEFAULT 0, content_ciphertext TEXT NOT NULL, encrypted_aes_key TEXT NOT NULL, aes_nonce TEXT NOT NULL, aes_tag TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) """) try: conn.execute("ALTER TABLE sects ADD COLUMN announcement TEXT") except Exception: pass for col_name, col_type in [("chat_type", "TEXT DEFAULT 'general'"), ("target_id", "INTEGER"), ("group_id", "INTEGER")]: try: conn.execute(f"ALTER TABLE sect_messages ADD COLUMN {col_name} {col_type}") except Exception: pass conn.execute("CREATE INDEX IF NOT EXISTS idx_sect_members_sect_id ON sect_members(sect_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_sect_members_user_id ON sect_members(user_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_sect_messages_sect_id ON sect_messages(sect_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_sect_books_sect_id ON sect_books(sect_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_sect_join_requests_sect_id ON sect_join_requests(sect_id)") conn.execute(""" CREATE TABLE IF NOT EXISTS app_releases ( id INTEGER PRIMARY KEY AUTOINCREMENT, platform TEXT, version TEXT, download_url TEXT, patch_url TEXT, file_size TEXT, release_notes TEXT, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(platform, version) ) """) # Migration: thêm cột patch_url cho bảng cũ nếu chưa có try: conn.execute("ALTER TABLE app_releases ADD COLUMN patch_url TEXT") except Exception: pass # Cột đã tồn tại try: existing = conn.execute("SELECT COUNT(*) as count FROM app_releases").fetchone() count = 0 if existing: if isinstance(existing, dict): count = existing.get('count', 0) elif hasattr(existing, 'keys'): count = existing['count'] else: count = existing[0] if count == 0: conn.execute(""" INSERT INTO app_releases (platform, version, download_url, file_size, release_notes) VALUES ('extension', '1.0.0', '/downloads/tts_extension.zip', '10.7 MB', 'Cập nhật dịch nhanh và tối ưu hóa Chrome Extension Helper'), ('desktop_linux', '0.0.0', 'https://huggingface.co/datasets/Cong123779/tienhiep-data/resolve/main/downloads/TienHiepAI-0.0.0.AppImage', '116 MB', 'Phiên bản AppImage beta dành cho Linux'), ('desktop_windows', '0.0.0', '#', '0 MB', 'Bản Windows Setup .EXE chính thức sắp ra mắt') """) except Exception as e: logger.warning(f"Failed to seed app_releases: {e}") conn.commit() def init_user_db(): conn = get_user_db_conn() try: _init_db_schema_for_conn(conn) logger.info("✔ Database initialized successfully.") except Exception as e: logger.error(f"❌ Database initialization failed: {e}") raise finally: conn.close() is_postgres = (type(conn).__name__ == "PgConnectionWrapper") if is_postgres: try: sqlite_conn = sqlite3.connect(USER_DB_PATH) sqlite_conn.row_factory = sqlite3.Row _init_db_schema_for_conn(sqlite_conn) sqlite_conn.close() logger.info("✔ SQLite fallback database synchronized successfully.") except Exception as sqlite_err: logger.warning(f"⚠️ Could not synchronize SQLite fallback database schema: {sqlite_err}") # --------------------------------------------------------------------------- # Books SQLite databases thread local caching # --------------------------------------------------------------------------- thread_local = threading.local() class SimpleCache: def __init__(self, maxsize=500): self.cache = OrderedDict() self.maxsize = maxsize self.lock = threading.Lock() def get(self, key): with self.lock: if key in self.cache: self.cache.move_to_end(key) return self.cache[key] return None def set(self, key, value): with self.lock: if key in self.cache: self.cache.move_to_end(key) self.cache[key] = value if len(self.cache) > self.maxsize: self.cache.popitem(last=False) count_cache = SimpleCache(maxsize=1000) query_cache = SimpleCache(maxsize=1000) cached_stats = None # Cache the resolved DB path to avoid re-reading YAML config on every call _cached_db_path = None _cached_db_path_time = 0 _DB_PATH_CACHE_DURATION = 60 # Re-check config every 60 seconds def ensure_db_exists(db_path): """Lazy download book SQLite databases from HuggingFace dataset if missing or incomplete.""" if os.path.exists(db_path) and os.path.getsize(db_path) > 1024 * 1024: return filename = os.path.basename(db_path) token = os.environ.get("HF_TOKEN") if not token: logger.warning(f"⚠️ HF_TOKEN not set, cannot download database file: {filename}") return logger.info(f"⏳ Lazy-downloading database from HF: {filename}...") try: from huggingface_hub import hf_hub_download hf_hub_download( repo_id="Cong123779/tienhiep-data", filename=filename, repo_type="dataset", token=token, local_dir=Config.ROOT_DIR, local_dir_use_symlinks=False, ) logger.info(f"✔ Successfully downloaded {filename}") except Exception as e: logger.error(f"❌ Failed to lazy-download database {filename}: {e}") def get_db(): global _cached_db_path, _cached_db_path_time now = time.time() if _cached_db_path is None or (now - _cached_db_path_time) > _DB_PATH_CACHE_DURATION: db_path = Config.DB_PATH try: config_path = os.path.join(Config.ROOT_DIR, "quick_translator", "config.yaml") if os.path.exists(config_path): import yaml with open(config_path, "r", encoding="utf-8") as f: config = yaml.safe_load(f) mode = config.get("translation", {}).get("mode", "advanced") candidate_db = os.path.join(Config.ROOT_DIR, f"merged_books_{mode}.db") ensure_db_exists(candidate_db) if os.path.exists(candidate_db): db_path = candidate_db except Exception as e: print(f"[WARNING] Error loading dynamic database path: {e}") if db_path == Config.DB_PATH: ensure_db_exists(db_path) _cached_db_path = db_path _cached_db_path_time = now else: db_path = _cached_db_path if not hasattr(thread_local, "connections"): thread_local.connections = {} if db_path not in thread_local.connections: # Final fallback check if not os.path.exists(db_path): ensure_db_exists(db_path) if not os.path.exists(db_path): # Create empty database to prevent crash logger.warning(f"Creating fallback empty database for {db_path}") conn = sqlite3.connect(db_path, factory=ProfiledSqliteConnection) try: conn.execute("CREATE TABLE IF NOT EXISTS books (id INTEGER PRIMARY KEY, title TEXT, author TEXT, categories TEXT, chapters_max INTEGER);") except Exception: pass else: conn = sqlite3.connect(db_path, factory=ProfiledSqliteConnection) conn.row_factory = sqlite3.Row conn.execute("PRAGMA synchronous=NORMAL;") conn.execute("PRAGMA cache_size=-30000;") conn.execute("PRAGMA temp_store=MEMORY;") conn.execute("PRAGMA mmap_size=268435456;") conn.execute("PRAGMA synchronous=OFF;") try: conn.execute("CREATE INDEX IF NOT EXISTS idx_categories ON books(categories);") conn.execute("CREATE INDEX IF NOT EXISTS idx_chapters_max ON books(chapters_max);") except Exception: pass thread_local.connections[db_path] = conn return thread_local.connections[db_path] def get_mode_connection(db_path): if not hasattr(thread_local, "connections"): thread_local.connections = {} if db_path not in thread_local.connections: ensure_db_exists(db_path) if os.path.exists(db_path): conn = sqlite3.connect(db_path, factory=ProfiledSqliteConnection) conn.row_factory = sqlite3.Row conn.execute("PRAGMA synchronous=NORMAL;") conn.execute("PRAGMA cache_size=-10000;") conn.execute("PRAGMA temp_store=MEMORY;") conn.execute("PRAGMA mmap_size=134217728;") conn.execute("PRAGMA synchronous=OFF;") thread_local.connections[db_path] = conn else: return None return thread_local.connections[db_path]