126 lines
4.6 KiB
Python
126 lines
4.6 KiB
Python
"""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,
|
|
},
|
|
)
|