feature/generador-instaladores-linux-windows

This commit is contained in:
2026-07-30 07:34:17 -06:00
parent cafe3f1b87
commit c3f1d70e23
34 changed files with 3498 additions and 252 deletions

View File

@@ -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: