Files
CloudRecoveryAS/app/db/job_repository.py

339 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)
}