"""Repositorio para gestionar jobs.""" from typing import Optional, List from datetime import datetime from dataclasses import dataclass import uuid from .database import db from ..constants import JobStatus @dataclass class Job: """Modelo de Job.""" job_id: str created_at: str updated_at: str source_path: str source_name: str source_hash: Optional[str] node_name: Optional[str] db_name: Optional[str] status: str attempts: int last_error: Optional[str] started_at: Optional[str] finished_at: Optional[str] total_ms: Optional[int] 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: """Repositorio para operaciones con jobs.""" @staticmethod def create(source_path: str, source_name: str, source_hash: str) -> str: """ Crea un nuevo job. Args: source_path: Ruta completa del archivo fuente source_name: Nombre del archivo source_hash: Hash del archivo Returns: job_id del job creado """ job_id = str(uuid.uuid4()) now = datetime.utcnow().isoformat() db.execute(""" INSERT INTO jobs ( job_id, created_at, updated_at, source_path, source_name, source_hash, status, attempts ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, (job_id, now, now, source_path, source_name, source_hash, JobStatus.QUEUED, 0)) return job_id @staticmethod def get(job_id: str) -> Optional[Job]: """Obtiene un job por ID.""" row = db.fetchone("SELECT * FROM jobs WHERE job_id = ?", (job_id,)) if not row: return None return Job(**dict(row)) @staticmethod def get_all( status: Optional[str] = None, limit: Optional[int] = None, offset: int = 0 ) -> List[Job]: """ Obtiene todos los jobs con filtros opcionales. Args: status: Filtrar por estado limit: Límite de resultados offset: Desplazamiento Returns: Lista de jobs """ query = "SELECT * FROM jobs" params = [] if status: query += " WHERE status = ?" params.append(status) query += " ORDER BY created_at DESC" if limit: query += f" LIMIT {limit} OFFSET {offset}" rows = db.fetchall(query, tuple(params)) return [Job(**dict(row)) for row in rows] @staticmethod def update_status( job_id: str, status: str, error: Optional[str] = None, increment_attempts: bool = False ): """Actualiza el estado de un job.""" now = datetime.utcnow().isoformat() updates = ["status = ?", "updated_at = ?"] params = [status, now] if error is not None: updates.append("last_error = ?") params.append(error) if increment_attempts: updates.append("attempts = attempts + 1") if status == JobStatus.EXTRACTING and not db.fetchone( "SELECT started_at FROM jobs WHERE job_id = ?", (job_id,) )["started_at"]: updates.append("started_at = ?") params.append(now) if status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED]: updates.append("finished_at = ?") params.append(now) params.append(job_id) db.execute( f"UPDATE jobs SET {', '.join(updates)} WHERE job_id = ?", tuple(params) ) @staticmethod def update_node_and_db(job_id: str, node_name: str, db_name: str): """Actualiza el nodo y base de datos de un job.""" now = datetime.utcnow().isoformat() db.execute( "UPDATE jobs SET node_name = ?, db_name = ?, updated_at = ? WHERE job_id = ?", (node_name, db_name, now, job_id) ) @staticmethod def update_timing( job_id: str, total_ms: Optional[int] = None, extract_ms: Optional[int] = None, restore_ms: Optional[int] = None, filelist_ms: Optional[int] = None ): """Actualiza los tiempos de ejecución.""" updates = [] params = [] if total_ms is not None: updates.append("total_ms = ?") params.append(total_ms) if extract_ms is not None: updates.append("extract_ms = ?") params.append(extract_ms) if restore_ms is not None: updates.append("restore_ms = ?") params.append(restore_ms) if filelist_ms is not None: updates.append("filelist_ms = ?") params.append(filelist_ms) if updates: params.append(job_id) db.execute( f"UPDATE jobs SET {', '.join(updates)} WHERE job_id = ?", tuple(params) ) @staticmethod def delete(job_id: str): """ Elimina un job y su rastro de hash. Se usa para diferir un job cuando no hay servidor de restauración activo: al borrarlo, el próximo escaneo del FileWatcher vuelve a detectar el archivo y reintenta (G8 del plan). """ 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 (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: """Obtiene estadísticas de jobs.""" stats = { "total": 0, "queued": 0, "running": 0, "completed": 0, "failed": 0 } rows = db.fetchall("SELECT status, COUNT(*) as count FROM jobs GROUP BY status") for row in rows: status = row["status"] count = row["count"] stats["total"] += count if status == JobStatus.QUEUED: stats["queued"] = count elif status in [JobStatus.EXTRACTING, JobStatus.RESTORING, JobStatus.CLEANING]: stats["running"] += count elif status == JobStatus.COMPLETED: stats["completed"] = count elif status in [JobStatus.FAILED, JobStatus.FAILED_RESTART, JobStatus.CANCELLED]: stats["failed"] += count return stats @staticmethod def get_average_times() -> dict: """Obtiene tiempos promedio de ejecución.""" row = db.fetchone(""" SELECT AVG(total_ms) as avg_total, AVG(extract_ms) as avg_extract, AVG(restore_ms) as avg_restore FROM jobs WHERE status = ? AND total_ms IS NOT NULL """, (JobStatus.COMPLETED,)) return { "total_ms": int(row["avg_total"] or 0), "extract_ms": int(row["avg_extract"] or 0), "restore_ms": int(row["avg_restore"] or 0) }