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
|
||||
|
||||
@@ -28,6 +28,8 @@ class Job:
|
||||
extract_ms: Optional[int]
|
||||
restore_ms: Optional[int]
|
||||
filelist_ms: Optional[int]
|
||||
# Fecha en que la retención borró del disco el archivo de este job (None = aún en disco).
|
||||
purged_at: Optional[str] = None
|
||||
|
||||
|
||||
class JobRepository:
|
||||
@@ -185,14 +187,109 @@ class JobRepository:
|
||||
"""
|
||||
db.execute("DELETE FROM jobs WHERE job_id = ?", (job_id,))
|
||||
|
||||
@staticmethod
|
||||
def get_obsolete_completed_by_node(days: int = 2) -> List[Job]:
|
||||
"""Jobs 'completed' obsoletos por nodo, candidatos a que se borre su archivo del disco.
|
||||
|
||||
Para cada node_name (no NULL) toma su restauración más reciente (MAX(finished_at))
|
||||
como referencia y devuelve los jobs completados de ese nodo con finished_at anterior a
|
||||
(referencia − days). Como la referencia es el máximo, la comparación estricta '<' NUNCA
|
||||
incluye la restauración más reciente; los nodos con una sola restauración quedan fuera.
|
||||
Se excluyen node_name NULL y los ya purgados (purged_at no nulo).
|
||||
|
||||
Args:
|
||||
days: días de antigüedad respecto al más reciente de cada nodo.
|
||||
|
||||
Returns:
|
||||
Lista de jobs obsoletos ordenada por nodo y fecha ascendente.
|
||||
"""
|
||||
modifier = f"-{int(days)} days"
|
||||
rows = db.fetchall(
|
||||
"""
|
||||
WITH refs AS (
|
||||
SELECT node_name, MAX(finished_at) AS ref_finished_at
|
||||
FROM jobs
|
||||
WHERE status = ? AND finished_at IS NOT NULL AND node_name IS NOT NULL
|
||||
GROUP BY node_name
|
||||
)
|
||||
SELECT j.* FROM jobs j
|
||||
JOIN refs r ON r.node_name = j.node_name
|
||||
WHERE j.status = ?
|
||||
AND j.finished_at IS NOT NULL
|
||||
AND j.purged_at IS NULL
|
||||
AND datetime(j.finished_at) < datetime(r.ref_finished_at, ?)
|
||||
ORDER BY j.node_name, j.finished_at
|
||||
""",
|
||||
(JobStatus.COMPLETED, JobStatus.COMPLETED, modifier),
|
||||
)
|
||||
return [Job(**dict(row)) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
def get_latest_completed_per_node() -> dict:
|
||||
"""node_name -> finished_at (MAX) de las restauraciones completadas de cada nodo.
|
||||
|
||||
Sirve como salvaguarda de la retención: la carpeta-fecha de esta referencia (la más
|
||||
reciente de cada nodo) nunca debe tocarse.
|
||||
"""
|
||||
rows = db.fetchall(
|
||||
"""
|
||||
SELECT node_name, MAX(finished_at) AS ref_finished_at
|
||||
FROM jobs
|
||||
WHERE status = ? AND finished_at IS NOT NULL AND node_name IS NOT NULL
|
||||
GROUP BY node_name
|
||||
""",
|
||||
(JobStatus.COMPLETED,),
|
||||
)
|
||||
return {row["node_name"]: row["ref_finished_at"] for row in rows}
|
||||
|
||||
@staticmethod
|
||||
def mark_purged(job_id: str) -> None:
|
||||
"""Marca que la retención ya borró del disco el archivo de este job.
|
||||
|
||||
No borra la fila: conserva el historial, las estadísticas y la vista de la UI; solo
|
||||
evita que la retención vuelva a intentar borrar un archivo que ya no existe.
|
||||
"""
|
||||
now = datetime.utcnow().isoformat()
|
||||
db.execute(
|
||||
"UPDATE jobs SET purged_at = ?, updated_at = ? WHERE job_id = ?",
|
||||
(now, now, job_id),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def exists_by_hash(source_hash: str) -> bool:
|
||||
"""Verifica si existe un job con el hash dado."""
|
||||
"""Verifica si existe un job con el hash dado (cualquier estado)."""
|
||||
row = db.fetchone(
|
||||
"SELECT COUNT(*) as count FROM jobs WHERE source_hash = ?",
|
||||
(source_hash,)
|
||||
)
|
||||
return row["count"] > 0
|
||||
|
||||
@staticmethod
|
||||
def has_blocking_job_by_hash(source_hash: str) -> bool:
|
||||
"""True si hay un job con ese hash EXITOSO o EN CURSO (dedup real).
|
||||
|
||||
Los estados terminales fallidos (failed/failed_restart/cancelled) NO bloquean: así,
|
||||
un fallo transitorio (p.ej. 'database is locked') deja de impedir para siempre el
|
||||
reproceso del archivo si reaparece.
|
||||
"""
|
||||
row = db.fetchone(
|
||||
"SELECT COUNT(*) as count FROM jobs "
|
||||
"WHERE source_hash = ? AND status NOT IN (?, ?, ?)",
|
||||
(source_hash, JobStatus.FAILED, JobStatus.FAILED_RESTART, JobStatus.CANCELLED),
|
||||
)
|
||||
return row["count"] > 0
|
||||
|
||||
@staticmethod
|
||||
def delete_failed_by_hash(source_hash: str) -> int:
|
||||
"""Elimina jobs terminales fallidos/cancelados con ese hash (permite un reintento fresco)."""
|
||||
cursor = db.execute(
|
||||
"DELETE FROM jobs WHERE source_hash = ? AND status IN (?, ?, ?)",
|
||||
(source_hash, JobStatus.FAILED, JobStatus.FAILED_RESTART, JobStatus.CANCELLED),
|
||||
)
|
||||
try:
|
||||
return cursor.rowcount if cursor else 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def get_stats() -> dict:
|
||||
|
||||
Reference in New Issue
Block a user