refactor(dron): Atomisidad - Módulo base compartido para drones

Principio "Menos es Más": código común centralizado en un solo lugar.

Cambios:
- Nuevo módulo Dron::Base con funcionalidad compartida:
  - logger: Logger compartido (evita crear múltiples instancias)
  - db_query: Ejecución de SQL con resultados
  - db_exec: Ejecución de SQL sin resultados
  - formato_duracion: Utilitario para formatear segundos
  - slice_safe: Slice seguro de strings
  - blank?: Verificación de strings vacíos/nulos

- Todos los módulos atómicos (ejecutor, vigilante, sanador, bitacora)
  ahora usan Base.logger, Base.db_query, Base.db_exec

Beneficios:
- Un solo lugar para corregir errores comunes
- Menos duplicación de código (~40% menos líneas)
- Consistencia en manejo de logs y DB
- Fácil extensión: nuevos drones heredan funcionalidad automática

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ricardo Monla
2026-04-07 11:48:55 -03:00
co-authored by Claude Opus 4.6
parent 3543e62263
commit 6e0e04d1e1
5 changed files with 206 additions and 667 deletions
+26 -149
View File
@@ -3,204 +3,81 @@
# 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 'base'
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
BACKOFF_BASE = 60
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
Base.logger.info("🩹 Dron Sanador: Re-intentando drones fallidos...")
fallidos = Base.db_query("SELECT * FROM bitacoras.dron_logs WHERE estado = 'failed' AND reintentos < #{MAX_REINTENTOS} AND cmd IS NOT NULL ORDER BY completed_at ASC")
return { reintentados: 0, omitidos: 0 } if fallidos.empty?
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
espera = BACKOFF_BASE * (2 ** dron['reintentos'].to_i)
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)")
Base.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']}")
Base.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
Base.logger.info("🩹 Re-intentando #{dron['dron_id']} (intento #{dron['reintentos'].to_i + 1}/#{MAX_REINTENTOS})")
ADN::DB::DronDB.incrementar_reintentos(dron['dron_id'])
resultado = Dron::Ejecutor.lanzar(cmd: dron['cmd'], nota: "Reintento: #{dron.dig('metadata', 'nota')}", flujo_id: dron['flujo_id'])
reintentados += 1 if resultado[:exit_code] == 0
notificar_fallo_persistente(dron) if dron['reintentos'].to_i >= MAX_REINTENTOS && resultado[:exit_code] != 0
end
end
puts "\n🩹 Resultado de reintentos:"
puts " Reintentados: #{reintentados}"
puts " Omitidos (backoff): #{omitidos}"
puts "\n🩹 Resultado: Reintentados=#{reintentados} | Omitidos=#{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)
Base.logger.info("🩹 Dron Sanador: Limpiando drones antiguos (>#{dias} días)...")
resultado = Base.db_query("SELECT COUNT(*) as cantidad FROM bitacoras.dron_logs WHERE estado IN ('completed', 'failed', 'zombie') AND completed_at < NOW() - INTERVAL '#{dias} days'")
cantidad = resultado.first['cantidad'].to_i
if cantidad == 0
@logger.info("🩹 No hay drones antiguos para limpiar")
return { eliminados: 0 }
end
return { eliminados: 0 } if cantidad == 0
if dry_run
@logger.info("🩹 [DRY RUN] Eliminaría #{cantidad} drones antiguos")
puts "\n🩹 Drones a limpiar: #{cantidad}"
Base.logger.info("🩹 [DRY RUN] Eliminaría #{cantidad} drones antiguos")
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}"
Base.db_exec("DELETE FROM bitacoras.dron_logs WHERE estado IN ('completed', 'failed', 'zombie') AND completed_at < NOW() - INTERVAL '#{dias} days'")
Base.logger.info("🩹 ✅ Limpiados #{cantidad} drones antiguos")
{ 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)
}
Base.logger.info("🩹 Dron Sanador: 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
private
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
# )
Base.logger.error("🚨 FALLO PERSISTENTE: #{dron['dron_id']} - #{Base.slice_safe(dron['cmd'], 50)}")
Base.db_exec("INSERT INTO bitacoras.dron_avances (dron_id, paso, descripcion, estado) VALUES ($1, -99, $2, 'critical')", [dron['dron_id'], "FALLO PERSISTENTE tras #{dron['reintentos']} reintentos"])
end
end
end