feature/generador-instaladores-linux-windows
This commit is contained in:
@@ -1,37 +1,54 @@
|
||||
"""Esquema y gestión de la base de datos SQLite."""
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from ..constants import DB_PATH
|
||||
|
||||
# Espera máxima (s) por un lock de SQLite antes de fallar. Con WAL + busy_timeout los
|
||||
# lectores (UI cada 5s) y escritores (workers + file_watcher) dejan de chocar con
|
||||
# "database is locked": esperan en vez de fallar de inmediato.
|
||||
_SQLITE_TIMEOUT_SECONDS = 30.0
|
||||
_SQLITE_BUSY_TIMEOUT_MS = 5000
|
||||
|
||||
|
||||
class DatabaseManager:
|
||||
"""Gestor de la base de datos SQLite."""
|
||||
|
||||
|
||||
def __init__(self, db_path: Optional[Path] = None):
|
||||
"""
|
||||
Inicializa el gestor de base de datos.
|
||||
|
||||
|
||||
Args:
|
||||
db_path: Ruta a la base de datos (usa DB_PATH por defecto)
|
||||
"""
|
||||
self.db_path = db_path or DB_PATH
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Serializa las escrituras del propio proceso (varios workers + file_watcher) para
|
||||
# eliminar las colisiones write-write; WAL cubre la concurrencia lectura/escritura.
|
||||
self._write_lock = threading.Lock()
|
||||
self._initialize_schema()
|
||||
|
||||
|
||||
def get_connection(self) -> sqlite3.Connection:
|
||||
"""Obtiene una conexión a la base de datos."""
|
||||
conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
|
||||
"""Obtiene una conexión a SQLite con protecciones de concurrencia (WAL + timeouts)."""
|
||||
conn = sqlite3.connect(
|
||||
str(self.db_path), check_same_thread=False, timeout=_SQLITE_TIMEOUT_SECONDS
|
||||
)
|
||||
conn.row_factory = sqlite3.Row
|
||||
# WAL: lectores concurrentes con un escritor (persistente en el archivo, idempotente).
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute(f"PRAGMA busy_timeout={_SQLITE_BUSY_TIMEOUT_MS}")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
return conn
|
||||
|
||||
def _initialize_schema(self):
|
||||
"""Crea las tablas si no existen."""
|
||||
with self.get_connection() as conn:
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
|
||||
# Tabla de jobs
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
@@ -51,7 +68,8 @@ class DatabaseManager:
|
||||
total_ms INTEGER,
|
||||
extract_ms INTEGER,
|
||||
restore_ms INTEGER,
|
||||
filelist_ms INTEGER
|
||||
filelist_ms INTEGER,
|
||||
purged_at TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
@@ -104,36 +122,62 @@ class DatabaseManager:
|
||||
)
|
||||
""")
|
||||
|
||||
# Migración idempotente: columna purged_at para marcar los respaldos que la
|
||||
# retención ya borró del disco. Se conserva la fila (historial/stats/UI) y solo
|
||||
# se anota que su archivo físico dejó de existir. Backward-compatible: columna
|
||||
# nullable que las versiones anteriores de la app simplemente ignoran.
|
||||
cursor.execute("PRAGMA table_info(jobs)")
|
||||
job_columns = {row[1] for row in cursor.fetchall()}
|
||||
if "purged_at" not in job_columns:
|
||||
cursor.execute("ALTER TABLE jobs ADD COLUMN purged_at TEXT")
|
||||
|
||||
# Índices
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at)")
|
||||
# Soporta la retención por nodo (GROUP BY node_name + filtro por finished_at).
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_jobs_node_finished "
|
||||
"ON jobs(node_name, finished_at)"
|
||||
)
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_events_job ON events(job_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_job_steps_job ON job_steps(job_id)")
|
||||
|
||||
|
||||
conn.commit()
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def execute(self, query: str, params: tuple = ()) -> sqlite3.Cursor:
|
||||
"""Ejecuta una consulta SQL."""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(query, params)
|
||||
conn.commit()
|
||||
return cursor
|
||||
|
||||
"""Ejecuta una escritura, serializada por lock de proceso y con conexión cerrada."""
|
||||
with self._write_lock:
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(query, params)
|
||||
conn.commit()
|
||||
return cursor
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def fetchone(self, query: str, params: tuple = ()) -> Optional[sqlite3.Row]:
|
||||
"""Ejecuta una consulta y devuelve una fila."""
|
||||
with self.get_connection() as conn:
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(query, params)
|
||||
return cursor.fetchone()
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def fetchall(self, query: str, params: tuple = ()) -> list[sqlite3.Row]:
|
||||
"""Ejecuta una consulta y devuelve todas las filas."""
|
||||
with self.get_connection() as conn:
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(query, params)
|
||||
return cursor.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# Instancia global
|
||||
|
||||
Reference in New Issue
Block a user