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

@@ -0,0 +1,125 @@
"""Programador de mantenimiento diario in-process.
Corre una tarea (p.ej. la retención) UNA VEZ por día calendario, dentro del propio proceso de
CloudRestoreAS. No depende de cron/systemd externos (el despliegue es embedded-only) ni del
event loop de Qt: usa un hilo daemon con el mismo patrón que ``FileWatcher``.
La marca de la última corrida se persiste en la tabla ``config`` (vía ``ConfigRepository``), de
modo que:
- corre a lo más una vez por día calendario ("claim" de la fecha ANTES de ejecutar), y
- si el servicio estuvo caído se "pone al día" en el primer arranque de un día nuevo.
"""
from datetime import datetime
from threading import Event, Thread
from typing import Callable, Optional
from ..db.config_repository import ConfigRepository
from ..db.event_repository import EventRepository
from ..utils.logger import app_logger
class DailyMaintenanceScheduler:
"""Ejecuta ``task`` una vez al día en un hilo daemon."""
def __init__(
self,
task: Callable[[], None],
*,
check_interval_seconds: int = 3600,
run_at_hour: Optional[int] = 3,
state_key: str = "retention_last_run",
clock: Callable[[], datetime] = datetime.now,
) -> None:
self._task = task
self._check_interval = max(60, int(check_interval_seconds))
self._run_at_hour = run_at_hour
self._state_key = state_key
self._clock = clock
self._stop_event = Event()
self._thread: Optional[Thread] = None
def start(self) -> None:
"""Inicia el hilo de mantenimiento (hace un chequeo inmediato de 'catch-up')."""
if self._thread and self._thread.is_alive():
app_logger.warning("DailyMaintenanceScheduler ya está corriendo")
return
self._stop_event.clear()
self._thread = Thread(target=self._run, daemon=True)
self._thread.start()
app_logger.info(
f"Mantenimiento diario iniciado (hora={self._run_at_hour}, "
f"cada {self._check_interval}s)"
)
def stop(self, timeout: float = 30.0) -> None:
"""Detiene el hilo (timeout amplio: la limpieza puede tardar)."""
if self._thread:
self._stop_event.set()
self._thread.join(timeout=timeout)
app_logger.info("Mantenimiento diario detenido")
def trigger_now(self) -> None:
"""Corre la tarea de inmediato sin importar la fecha (uso manual/validación)."""
self._execute(force=True)
# -- Interno ---------------------------------------------------------------------
def _run(self) -> None:
while not self._stop_event.is_set():
try:
self._run_if_due()
except Exception as e:
app_logger.error(
f"Error en DailyMaintenanceScheduler: {e}", exc_info=True
)
self._stop_event.wait(self._check_interval)
def _run_if_due(self) -> None:
now = self._clock()
if self._is_due(now, self._load_state()):
self._execute(now=now)
def _is_due(self, now: datetime, state: dict) -> bool:
if state.get("last_run_date") == now.date().isoformat():
return False # ya corrió hoy
if self._run_at_hour is None:
return True # primera oportunidad de un día nuevo
return now.hour >= self._run_at_hour
def _execute(self, now: Optional[datetime] = None, force: bool = False) -> None:
now = now or self._clock()
today = now.date().isoformat()
# Claim al inicio: marca la fecha ANTES de correr para garantizar "máximo 1/día"
# aunque la corrida falle o el proceso muera a mitad (la tarea es idempotente).
if not force:
self._save_state(today, now, "running")
try:
self._task()
status = "ok"
except Exception as e:
app_logger.error(
f"Fallo en la tarea de mantenimiento diaria: {e}", exc_info=True
)
EventRepository.create("ERROR", f"Fallo en la limpieza diaria: {e}")
status = f"error: {e}"
if not force:
self._save_state(today, now, status)
def _load_state(self) -> dict:
state = ConfigRepository.get(self._state_key, {})
return state if isinstance(state, dict) else {}
def _save_state(self, run_date: str, now: datetime, status: str) -> None:
ConfigRepository.set(
self._state_key,
{
"last_run_date": run_date,
"last_run_at": now.isoformat(),
"last_status": status,
},
)