# frozen_string_literal: true # Dron::Sanador — Módulo atómico para reparación de drones # # Responsabilidad única: Reparar drones zombies/fallidos # - Re-intenta drones fallidos (máx 3 intentos, backoff exponencial) # - Limpia drones completados antiguos # - Notifica fallos persistentes # # Uso: # require_relative 'dron/sanador' # Dron::Sanador.reintentar_fallidos # Dron::Sanador.limpiar_completados require_relative '../../db/core/dron_db' require_relative '../../core/constants' require_relative '../../core/logger' module Dron class Sanador # Máximo de reintentos antes de marcar como fallo persistente MAX_REINTENTOS = 3 # Backoff exponencial: 1min, 2min, 4min entre reintentos BACKOFF_BASE = 60 # segundos # Días de antigüedad para limpiar completados CLEANUP_DAYS = 7 class << self # Re-intentar drones fallidos (máx 3 intentos con backoff) # # @param dry_run [Boolean] Si true, solo muestra qué haría # @return [Hash] Resultado de la operación def reintentar_fallidos(dry_run: false) @logger = ADN::Logger.new @logger.info("🩹 Dron Sanador: Re-intentando drones fallidos...") # Obtener drones fallidos que puedan reintentarse sql = <<-SQL SELECT * FROM bitacoras.dron_logs WHERE estado = 'failed' AND reintentos < #{MAX_REINTENTOS} AND cmd IS NOT NULL ORDER BY completed_at ASC SQL require_relative '../../db/core/bitacora_db' fallidos = BitacorasDB::BitacoraDB.ejecutar(sql) if fallidos.empty? @logger.info("🩹 No hay drones fallidos para re-intentar") return { reintentados: 0, omitidos: 0 } end reintentados = 0 omitidos = 0 fallidos.each do |dron| reintentos = dron['reintentos'].to_i # Calcular tiempo de espera con backoff exponencial espera = BACKOFF_BASE * (2 ** reintentos) # 60s, 120s, 240s # Verificar si ya pasó el tiempo de backoff tiempo_desde_fallo = Time.now - dron['completed_at'] if tiempo_desde_fallo < espera @logger.debug("⏳ #{dron['dron_id']} en backoff (#{tiempo_desde_fallo.round}s/#{espera}s)") omitidos += 1 next end if dry_run @logger.info("🩹 [DRY RUN] Re-intentaría: #{dron['dron_id']}") reintentados += 1 else # Re-intentar el comando @logger.info("🩹 Re-intentando #{dron['dron_id']} (intento #{reintentos + 1}/#{MAX_REINTENTOS})") # Incrementar contador de reintentos nuevos_reintentos = ADN::DB::DronDB.incrementar_reintentos(dron['dron_id']) # Volver a lanzar el comando require_relative 'ejecutor' resultado = Dron::Ejecutor.lanzar( cmd: dron['cmd'], nota: "Reintento #{nuevos_reintentos + 1}: #{dron.dig('metadata', 'nota')}", flujo_id: dron['flujo_id'] ) if resultado[:exit_code] == 0 @logger.info("🩹 ✅ Reintento exitoso: #{dron['dron_id']}") reintentados += 1 else @logger.warn("🩹 ❌ Reintento fallido: #{dron['dron_id']}") # Si llegó al máximo de reintentos, notificar if nuevos_reintentos >= MAX_REINTENTOS notificar_fallo_persistente(dron) end end end end puts "\n🩹 Resultado de reintentos:" puts " Reintentados: #{reintentados}" puts " Omitidos (backoff): #{omitidos}" { reintentados: reintentados, omitidos: omitidos } end # Limpiar drones completados/failed/zombies antiguos # # @param dias [Integer] Días de antigüedad para limpiar # @param dry_run [Boolean] Si true, solo muestra qué limpiaría # @return [Hash] Resultado de la operación def limpiar_antiguos(dias: CLEANUP_DAYS, dry_run: false) @logger = ADN::Logger.new @logger.info("🩹 Dron Sanador: Limpiando drones antiguos (>#{dias} días)...") sql = <<-SQL SELECT COUNT(*) as cantidad FROM bitacoras.dron_logs WHERE estado IN ('completed', 'failed', 'zombie') AND completed_at < NOW() - INTERVAL '#{dias} days' SQL require_relative '../../db/core/bitacora_db' resultado = BitacorasDB::BitacoraDB.ejecutar(sql) cantidad = resultado.first['cantidad'].to_i if cantidad == 0 @logger.info("🩹 No hay drones antiguos para limpiar") return { eliminados: 0 } end if dry_run @logger.info("🩹 [DRY RUN] Eliminaría #{cantidad} drones antiguos") puts "\n🩹 Drones a limpiar: #{cantidad}" return { eliminados: 0 } end # Eliminar drones antiguos sql_delete = <<-SQL DELETE FROM bitacoras.dron_logs WHERE estado IN ('completed', 'failed', 'zombie') AND completed_at < NOW() - INTERVAL '#{dias} days' SQL BitacorasDB::BitacoraDB.ejecutar(sql_delete) @logger.info("🩹 ✅ Limpiados #{cantidad} drones antiguos") puts "\n🩹 Drones eliminados: #{cantidad}" { eliminados: cantidad } end # Sanear toda la flota (reintentar + limpiar) # # @param dias_limpieza [Integer] Días para limpieza # @param dry_run [Boolean] Si true, modo simulación # @return [Hash] Resultado combinado def sanear(dias_limpieza: CLEANUP_DAYS, dry_run: false) @logger = ADN::Logger.new @logger.info("🩹 Dron Sanador: Iniciando saneamiento completo...") resultados = { reintentos: reintentar_fallidos(dry_run: dry_run), limpieza: limpiar_antiguos(dias: dias_limpieza, dry_run: dry_run) } puts "\n🩹 Saneamiento completado" puts " Reintentos: #{resultados[:reintentos][:reintentados]}" puts " Limpieza: #{resultados[:limpieza][:eliminados]}" resultados end # Notificar fallo persistente (tras 3 reintentos fallidos) # # @param dron [Hash] Datos del dron con fallo persistente def notificar_fallo_persistente(dron) @logger = ADN::Logger.new @logger.error("🚨 FALLO PERSISTENTE: #{dron['dron_id']}") @logger.error(" Comando: #{dron['cmd']}") @logger.error(" Reintentos: #{dron['reintentos']}") # Registrar en dron_avances como incidente crítico sql = <<-SQL INSERT INTO bitacoras.dron_avances (dron_id, paso, descripcion, estado) VALUES ($1, -99, $2, 'critical') RETURNING * SQL descripcion = "FALLO PERSISTENTE tras #{dron['reintentos']} reintentos: #{dron['cmd']&.slice(0, 100)}" BitacorasDB::BitacoraDB.ejecutar(sql, [dron['dron_id'], descripcion]) # TODO: Integrar con dron_mensajero para notificar por Slack/email # Dron::Mensajero.notificar( # tipo: 'critical', # mensaje: "Dron #{dron['dron_id']} falló persistentemente tras #{dron['reintentos']} reintentos", # contexto: dron # ) end end end end