feat(dron): Migración a AtomicDrones con arquitectura DB-First
Implementación del sistema AtomicDrones basado en "Menos es Más": - Refactorización de dron.rb a dispatcher + módulos atómicos - Nuevos módulos: ejecutor, vigilante, sanador, bitacora - DronDB para acceso a datos en PostgreSQL - Tablas: dron_logs, dron_avances, dron_metricas - Views: vw_dron_estado, vw_dron_zombies, vw_dron_metricas_semanal - Health check con detección de zombies (heartbeat > 5min) - Dashboard compacto de la flota - Saneamiento con reintentos (backoff exponencial) y limpieza Comandos soportados: - dron lanzar: Ejecutar comando como dron - dron flota: Dashboard de la flota - dron salud: Health check activo - dron estado <ID>: Estado detallado - dron sanear: Saneamiento completo - dron limpiar: Limpieza de antiguos - dron reintentar: Re-intentar fallidos Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
b5e89144e3
commit
3543e62263
@@ -0,0 +1,207 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user