339 lines
11 KiB
Python
339 lines
11 KiB
Python
"""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)
|
||
}
|