# SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2025 Allaun """Database connection management with connection pooling. This module provides SQLite database connection management with basic connection pooling using only standard library. Key Features: - SQLite connection management - Basic connection pooling - Thread-safe connection handling - Transaction management - Error handling with resilience Dependencies: - sqlite3 (standard library only) - threading (standard library only) """ import sqlite3 import threading from pathlib import Path from typing import Optional, Any, Dict, List, Tuple, Union, TypeVar T = TypeVar('T') class DatabaseConnection: """SQLite database connection with basic pooling. Responsibilities: - Manage database connections - Provide thread-safe access - Handle transactions - Manage connection lifecycle Thread Safety: - Singleton access via get_instance() is fully thread-safe - Instance creation happens inside a lock to prevent race conditions - Each thread gets its own connection via thread-local storage - Direct instantiation is allowed but not recommended for singleton pattern """ _instances: Dict[str, 'DatabaseConnection'] = {} _lock = threading.Lock() def __init__(self, db_path: str = "output/index.db"): """Initialize database connection. Args: db_path: Path to SQLite database file Note: For thread-safe singleton access, use get_instance() instead of direct instantiation. Direct instantiation bypasses the singleton pattern and may create multiple connections to the same database. """ if db_path == ":memory:": self.db_path = ":memory:" else: self.db_path = str(Path(db_path).absolute()) self._local = threading.local() # Track initialization to detect potential issues self._initialized = True @classmethod def get_instance(cls, db_path: str = "output/index.db") -> 'DatabaseConnection': """Get singleton instance of DatabaseConnection. Thread-safe singleton access. Only ONE instance per db_path is created, even under concurrent load. Instance creation happens inside a lock to prevent race conditions that could cause: - Multiple connections to the same database - WAL file corruption - Transaction isolation violations - Memory leaks from orphaned connections Args: db_path: Path to SQLite database file Returns: DatabaseConnection instance for the specified db_path Example: >>> db1 = DatabaseConnection.get_instance("mydb.db") >>> db2 = DatabaseConnection.get_instance("mydb.db") >>> db1 is db2 # True - same instance """ with cls._lock: if db_path not in cls._instances: # Create instance inside lock to prevent race conditions cls._instances[db_path] = cls(db_path) return cls._instances[db_path] def get_connection(self) -> sqlite3.Connection: """Get thread-local database connection. Returns: sqlite3.Connection instance """ if not hasattr(self._local, 'connection'): # Create database directory if it doesn't exist db_dir = Path(self.db_path).parent if db_dir and not db_dir.exists(): db_dir.mkdir(parents=True, exist_ok=True) # Connect to database with timeout and isolation level connection = sqlite3.connect( self.db_path, timeout=30.0, isolation_level='IMMEDIATE', check_same_thread=False ) # Configure connection for better performance connection.execute('PRAGMA journal_mode=WAL') connection.execute('PRAGMA synchronous=NORMAL') connection.execute('PRAGMA temp_store=MEMORY') connection.execute('PRAGMA cache_size=-20000') # 20MB cache # Enable foreign key constraints connection.execute('PRAGMA foreign_keys=ON') self._local.connection = connection return self._local.connection def execute( self, query: str, params: Optional[Union[Tuple[Any, ...], Dict[str, Any]]] = None ) -> sqlite3.Cursor: """Execute SQL query with parameters. Args: query: SQL query to execute params: Query parameters Returns: sqlite3.Cursor with results """ conn = self.get_connection() try: if params: return conn.execute(query, params) else: return conn.execute(query) except sqlite3.Error as e: print(f"[ERROR] Database query failed: {e}") raise def executemany( self, query: str, params_list: List[Union[Tuple[Any, ...], Dict[str, Any]]] ) -> sqlite3.Cursor: """Execute SQL query with multiple parameter sets. Args: query: SQL query to execute params_list: List of parameter tuples Returns: sqlite3.Cursor with results """ conn = self.get_connection() try: return conn.executemany(query, params_list) except sqlite3.Error as e: print(f"[ERROR] Database batch query failed: {e}") raise def commit(self) -> None: """Commit current transaction.""" try: self.get_connection().commit() except sqlite3.Error as e: print(f"[ERROR] Database commit failed: {e}") raise def rollback(self) -> None: """Roll back current transaction.""" try: self.get_connection().rollback() except sqlite3.Error as e: print(f"[ERROR] Database rollback failed: {e}") raise def close(self) -> None: """Close database connection.""" if hasattr(self._local, 'connection'): try: self._local.connection.close() except sqlite3.Error as e: print(f"[ERROR] Database connection close failed: {e}") finally: del self._local.connection def __del__(self) -> None: """Clean up database connection when object is destroyed.""" self.close() def initialize_database(self) -> None: """Initialize database schema if it doesn't exist.""" conn = self.get_connection() # Delegate to schema manager to ensure canonical schema and migrations try: from nodupe.tools.databases.schema import DatabaseSchema schema = DatabaseSchema(conn) schema.create_schema() except Exception: # Fallback: create minimal tables if schema manager fails conn.execute(''' CREATE TABLE IF NOT EXISTS files ( id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT NOT NULL UNIQUE, size INTEGER NOT NULL, modified_time INTEGER NOT NULL, hash TEXT, is_duplicate BOOLEAN DEFAULT FALSE, duplicate_of INTEGER, FOREIGN KEY (duplicate_of) REFERENCES files(id) ) ''') conn.execute('CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)') conn.execute('CREATE INDEX IF NOT EXISTS idx_files_hash ON files(hash)') conn.execute('CREATE INDEX IF NOT EXISTS idx_files_size ON files(size)') conn.execute('CREATE INDEX IF NOT EXISTS idx_files_duplicate ON files(is_duplicate)') conn.commit() def get_connection(db_path: str = "output/index.db") -> DatabaseConnection: """Get database connection instance. Args: db_path: Path to SQLite database file Returns: DatabaseConnection instance """ return DatabaseConnection.get_instance(db_path)