"""Worker para procesar jobs de restauración.""" import time import shutil from pathlib import Path from typing import Optional from datetime import datetime from PySide6.QtCore import QObject, Signal, QRunnable from ..constants import JobStatus, StepType from ..db.job_repository import JobRepository from ..db.job_step_repository import JobStepRepository from ..db.event_repository import EventRepository from ..db.node_repository import NodeRepository from ..extract.seven_zip import SevenZipExtractor from ..sql.sql_manager import SQLServerManager from ..utils.logger import app_logger class RestoreWorkerSignals(QObject): """Señales para comunicación con la UI.""" job_started = Signal(str) # job_id job_progress = Signal(str, str) # job_id, status job_completed = Signal(str, bool) # job_id, success error_occurred = Signal(str, str) # job_id, error class RestoreWorker(QRunnable): """Worker que procesa un job de restauración.""" def __init__( self, job_id: str, config: dict, dry_run: bool = False ): """ Inicializa el worker. Args: job_id: ID del job a procesar config: Configuración de la aplicación dry_run: Modo dry run (no ejecuta RESTORE) """ super().__init__() self.job_id = job_id self.config = config self.dry_run = dry_run self.signals = RestoreWorkerSignals() self._extract_dir: Optional[str] = None self._bak_path: Optional[str] = None def run(self): """Ejecuta el procesamiento del job.""" start_time = time.time() try: app_logger.info(f"Iniciando procesamiento de job {self.job_id}") self.signals.job_started.emit(self.job_id) # Obtener job job = JobRepository.get(self.job_id) if not job: raise ValueError(f"Job {self.job_id} no encontrado") # Pipeline de procesamiento self._process_node_mapping(job) self._extract_backup(job) self._restore_database(job) self._cleanup(job) # Actualizar tiempos total_ms = int((time.time() - start_time) * 1000) JobRepository.update_timing(self.job_id, total_ms=total_ms) # Marcar como completado JobRepository.update_status(self.job_id, JobStatus.COMPLETED) app_logger.info( f"Job {self.job_id} completado exitosamente en {total_ms}ms" ) self.signals.job_completed.emit(self.job_id, True) except Exception as e: error_msg = str(e) app_logger.error(f"Error procesando job {self.job_id}: {error_msg}", exc_info=True) JobRepository.update_status( self.job_id, JobStatus.FAILED, error=error_msg, increment_attempts=True ) EventRepository.create("ERROR", f"Job falló: {error_msg}", self.job_id) self.signals.error_occurred.emit(self.job_id, error_msg) self.signals.job_completed.emit(self.job_id, False) def _process_node_mapping(self, job): """Procesa el mapeo de nodo a base de datos.""" step_id = JobStepRepository.create(self.job_id, StepType.NODE_MAPPING) try: # Obtener node name del archivo (con extensión, uppercase) node_name = Path(job.source_name).name.upper() # Buscar mapeo en la tabla nodes db_name = NodeRepository.get_db_for_node(node_name) if not db_name: raise ValueError( f"NODE_NOT_MAPPED: No existe mapeo activo para nodo '{node_name}'" ) # Actualizar job con node_name y db_name JobRepository.update_node_and_db(self.job_id, node_name, db_name) app_logger.info(f"Node '{node_name}' mapeado a DB '{db_name}'") JobStepRepository.complete(step_id, exit_code=0, stdout=f"DB: {db_name}") except Exception as e: JobStepRepository.complete(step_id, exit_code=1, error=str(e)) raise def _extract_backup(self, job): """Extrae el backup del archivo ZIP.""" JobRepository.update_status(self.job_id, JobStatus.EXTRACTING) self.signals.job_progress.emit(self.job_id, JobStatus.EXTRACTING) step_id = JobStepRepository.create(self.job_id, StepType.EXTRACT) extract_start = time.time() try: # Configuración seven_zip_path = self.config["paths"]["seven_zip_exe"] extract_base = self.config["paths"]["extract_folder"] timeout_minutes = self.config["timeouts"]["extract_minutes"] # Crear carpeta de extracción para este job self._extract_dir = str(Path(extract_base) / self.job_id) Path(self._extract_dir).mkdir(parents=True, exist_ok=True) # Extraer extractor = SevenZipExtractor(seven_zip_path) # Si es multipart, asegurar que usamos el primer archivo source_path = job.source_path if extractor.is_multipart(source_path): source_path = extractor.get_first_part(source_path) app_logger.info(f"Archivo multipart detectado, usando: {source_path}") exit_code, stdout, stderr = extractor.extract( source_path, self._extract_dir, timeout_minutes ) extract_ms = int((time.time() - extract_start) * 1000) JobRepository.update_timing(self.job_id, extract_ms=extract_ms) if exit_code != 0: raise RuntimeError(f"7-Zip falló con código {exit_code}: {stderr}") JobStepRepository.complete( step_id, exit_code=exit_code, stdout=stdout[:1000] if stdout else None, stderr=stderr[:1000] if stderr else None ) # Buscar archivo .bak locate_step_id = JobStepRepository.create(self.job_id, StepType.LOCATE_BAK) self._bak_path = SevenZipExtractor.find_bak_file(self._extract_dir) if not self._bak_path: raise FileNotFoundError("No se encontró archivo .bak en el archivo extraído") JobStepRepository.complete( locate_step_id, exit_code=0, stdout=f"BAK: {Path(self._bak_path).name}" ) except Exception as e: JobStepRepository.complete(step_id, exit_code=1, error=str(e)) raise def _restore_database(self, job): """Restaura la base de datos desde el backup.""" JobRepository.update_status(self.job_id, JobStatus.RESTORING) self.signals.job_progress.emit(self.job_id, JobStatus.RESTORING) # Recargar job para obtener db_name actualizado job = JobRepository.get(self.job_id) if not job.db_name: raise ValueError("DB name no está configurado en el job") # Conectar a SQL connect_step_id = JobStepRepository.create(self.job_id, StepType.SQL_CONNECT) try: sql_config = self.config["sql"] sql_manager = SQLServerManager( server=sql_config["server"], use_windows_auth=sql_config["use_windows_auth"], username=sql_config.get("username"), password=sql_config.get("password") ) # Test connection success, error = sql_manager.test_connection() if not success: raise RuntimeError(f"Conexión SQL falló: {error}") JobStepRepository.complete(connect_step_id, exit_code=0) except Exception as e: JobStepRepository.complete(connect_step_id, exit_code=1, error=str(e)) raise # Obtener FILELISTONLY filelist_step_id = JobStepRepository.create(self.job_id, StepType.FILELIST) filelist_start = time.time() try: logical_files, error = sql_manager.get_filelist_from_backup( self._bak_path, timeout_minutes=self.config["timeouts"]["restore_minutes"] ) filelist_ms = int((time.time() - filelist_start) * 1000) JobRepository.update_timing(self.job_id, filelist_ms=filelist_ms) if error: raise RuntimeError(error) files_info = ", ".join([f"{lf.logical_name}({lf.type})" for lf in logical_files]) JobStepRepository.complete( filelist_step_id, exit_code=0, stdout=files_info[:1000] ) except Exception as e: JobStepRepository.complete(filelist_step_id, exit_code=1, error=str(e)) raise # RESTORE DATABASE restore_step_id = JobStepRepository.create(self.job_id, StepType.RESTORE) restore_start = time.time() try: data_folder = self.config["paths"]["data_sql_folder"] success, stdout, error = sql_manager.restore_database( db_name=job.db_name, backup_path=self._bak_path, data_folder=data_folder, logical_files=logical_files, timeout_minutes=self.config["timeouts"]["restore_minutes"], dry_run=self.dry_run ) restore_ms = int((time.time() - restore_start) * 1000) JobRepository.update_timing(self.job_id, restore_ms=restore_ms) if not success: raise RuntimeError(error or "RESTORE falló sin mensaje de error") JobStepRepository.complete( restore_step_id, exit_code=0, stdout=stdout[:1000] if stdout else None ) except Exception as e: JobStepRepository.complete(restore_step_id, exit_code=1, error=str(e)) raise def _cleanup(self, job): """Limpia archivos temporales y mueve el ZIP.""" JobRepository.update_status(self.job_id, JobStatus.CLEANING) self.signals.job_progress.emit(self.job_id, JobStatus.CLEANING) step_id = JobStepRepository.create(self.job_id, StepType.CLEANUP) try: # Eliminar carpeta de extracción if self._extract_dir and Path(self._extract_dir).exists(): shutil.rmtree(self._extract_dir) app_logger.info(f"Carpeta de extracción eliminada: {self._extract_dir}") # Mover ZIP a Processed processed_folder = Path(self.config["paths"]["processed_folder"]) date_folder = processed_folder / datetime.now().strftime("%Y-%m-%d") date_folder.mkdir(parents=True, exist_ok=True) source_path = Path(job.source_path) dest_path = date_folder / source_path.name # Si es multipart, mover todas las partes if SevenZipExtractor.is_multipart(str(source_path)): # Buscar todas las partes base_name = source_path.stem.split('.zip')[0] parts = list(source_path.parent.glob(f"{base_name}.zip.*")) for part in parts: part_dest = date_folder / part.name shutil.move(str(part), str(part_dest)) app_logger.info(f"Movido: {part.name} -> {part_dest}") else: shutil.move(str(source_path), str(dest_path)) app_logger.info(f"Movido: {source_path.name} -> {dest_path}") JobStepRepository.complete(step_id, exit_code=0) except Exception as e: # No fallar el job por errores de limpieza app_logger.warning(f"Error en limpieza (no crítico): {e}") JobStepRepository.complete(step_id, exit_code=1, error=str(e))