feature/generador-instaladores-linux-windows
This commit is contained in:
125
app/engine/maintenance_scheduler.py
Normal file
125
app/engine/maintenance_scheduler.py
Normal 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,
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user