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