Esta guía describe el proceso completo de migración del sistema InvernaderosAPI de arquitectura single-tenant a multi-tenant, permitiendo gestionar múltiples empresas/clientes, cada una con N invernaderos.
Duración estimada: 2-3 horas (incluyendo backups y validaciones) Downtime necesario: 30-60 minutos (solo durante aplicación de migraciones SQL críticas) Riesgo: MEDIO (mitigado con backups completos y scripts de rollback)
- ❌ Un único cliente/empresa por despliegue
- ❌ Topic MQTT fijo:
GREENHOUSE - ❌
greenhouse_idcomo VARCHAR en TimescaleDB - ❌ Sin tenant_id en tablas
- ✅ Múltiples empresas en un solo despliegue
- ✅ Topics MQTT dinámicos:
GREENHOUSE/empresaID(e.g.,GREENHOUSE/SARA) - ✅
greenhouse_idcomo UUID con integridad referencial - ✅
tenant_iddenormalizado para queries optimizados - ✅ Compatibilidad con formato MQTT híbrido (JSON agregado + individual)
# Backup PostgreSQL (metadata - port 30433)
PGPASSWORD="AppToLast2023%" pg_dump -h 138.199.157.58 -p 30433 -U admin \
-d postgres --schema=metadata --format=custom \
-f backup_metadata_$(date +%Y%m%d_%H%M%S).dump
# Backup TimescaleDB (timeseries - port 30432)
PGPASSWORD="AppToLast2023%" pg_dump -h 138.199.157.58 -p 30432 -U admin \
-d postgres --schema=public --table=sensor_readings --format=custom \
-f backup_timescale_$(date +%Y%m%d_%H%M%S).dumpCRÍTICO: Verificar que los backups se crearon correctamente antes de continuar.
# Verificar tamaño de backups (deben ser > 0 bytes)
ls -lh backup_*.dump-- Conectar a PostgreSQL metadata (port 30433)
PGPASSWORD="AppToLast2023%" psql -h 138.199.157.58 -p 30433 -U admin -d postgres
-- VERIFICACIÓN 1: Contar tenants existentes (debe ser 0 antes de migración)
SELECT COUNT(*) FROM metadata.tenants;
-- VERIFICACIÓN 2: Contar greenhouses existentes
SELECT COUNT(*) FROM metadata.greenhouses;
-- VERIFICACIÓN 3: Contar sensores existentes
SELECT COUNT(*) FROM metadata.sensors;
-- VERIFICACIÓN 4: Verificar lecturas en TimescaleDB
\c postgres -h 138.199.157.58 -p 30432
SELECT COUNT(*) FROM public.sensor_readings;
SELECT COUNT(*) FROM public.sensor_readings WHERE greenhouse_id IS NULL;
-- IMPORTANTE: Si hay NULLs en greenhouse_id, la migración V8 FALLARÁRECOMENDACIÓN CRÍTICA: Ejecutar esta migración PRIMERO en un entorno de staging idéntico a producción.
- ✅ Mismas versiones de PostgreSQL/TimescaleDB
- ✅ Misma estructura de datos
- ✅ Mismo volumen de datos (usar backup de producción)
IMPORTANTE: Los scripts Flyway se ejecutan automáticamente al arrancar la aplicación si están en src/main/resources/db/migration/. Sin embargo, para mayor control, se recomienda ejecutarlos manualmente en este orden:
# Conectar a PostgreSQL metadata (port 30433)
PGPASSWORD="AppToLast2023%" psql -h 138.199.157.58 -p 30433 -U admin -d postgres
# Script 1: Expandir tabla tenants con campos de empresa
\i src/main/resources/db/migration/V3__add_tenant_company_fields.sql
-- Resultado esperado: 14 columnas añadidas, 2 constraints únicos
# Script 2: Añadir campos MQTT a greenhouses
\i src/main/resources/db/migration/V4__add_greenhouse_mqtt_fields.sql
-- Resultado esperado: 4 columnas añadidas, FK a tenants
# Script 3: Añadir campos MQTT a sensors + denormalizar tenant_id
\i src/main/resources/db/migration/V5__add_sensor_mqtt_fields.sql
-- Resultado esperado: 4 columnas añadidas, tenant_id propagado
# Script 4: Crear tabla actuators (NUEVA)
\i src/main/resources/db/migration/V6__create_actuators_table.sql
-- Resultado esperado: Tabla actuators creada con 17 campos
# Script 5: Migrar datos existentes a tenant DEFAULT
\i src/main/resources/db/migration/V7__migrate_existing_data.sql
-- Resultado esperado: Tenant DEFAULT creado, todos los datos migrados
# VERIFICACIÓN INTERMEDIA
SELECT * FROM metadata.tenants WHERE mqtt_topic_prefix = 'DEFAULT';
SELECT COUNT(*) FROM metadata.greenhouses WHERE tenant_id IS NOT NULL;
SELECT COUNT(*) FROM metadata.sensors WHERE tenant_id IS NOT NULL;¿Por qué es crítico?
- Cambia
greenhouse_idde VARCHAR(50) a UUID - Puede tardar en tablas grandes (millones de filas)
- Requiere validación previa de datos
# Conectar a TimescaleDB (port 30432)
PGPASSWORD="AppToLast2023%" psql -h 138.199.157.58 -p 30432 -U admin -d postgres
-- PRE-VALIDACIÓN (CRÍTICO)
-- Esta query DEBE devolver 0. Si devuelve > 0, hay datos inválidos.
SELECT COUNT(*) FROM public.sensor_readings WHERE greenhouse_id IS NULL;
SELECT COUNT(*) FROM public.sensor_readings WHERE greenhouse_id !~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$';
-- Si las validaciones pasan (COUNT = 0), ejecutar migración
\i src/main/resources/db/migration/V8__timescaledb_uuid_migration.sql
-- POST-VALIDACIÓN
SELECT
column_name,
data_type,
is_nullable
FROM information_schema.columns
WHERE table_name = 'sensor_readings'
AND column_name IN ('greenhouse_id', 'tenant_id');
-- Resultado esperado:
-- greenhouse_id | uuid | NO
-- tenant_id | uuid | YES# Script 6: Crear índices optimizados para queries multi-tenant
\i src/main/resources/db/migration/V9__add_indexes_multi_tenant.sql
-- VERIFICACIÓN: Contar índices creados
SELECT schemaname, tablename, indexname
FROM pg_indexes
WHERE tablename IN ('tenants', 'greenhouses', 'sensors', 'actuators', 'sensor_readings')
ORDER BY tablename, indexname;VERIFICAR: Los siguientes repositories deben existir con los métodos indicados.
// TenantRepository.kt (NUEVO)
interface TenantRepository : JpaRepository<Tenant, UUID> {
fun findByMqttTopicPrefix(mqttTopicPrefix: String): Tenant?
fun findByEmail(email: String): Tenant?
fun findByTaxId(taxId: String): Tenant?
}
// GreenhouseRepository.kt (ACTUALIZADO)
interface GreenhouseRepository : JpaRepository<Greenhouse, UUID> {
fun findByTenantIdAndIsActive(tenantId: UUID, isActive: Boolean): List<Greenhouse>
fun findByMqttTopic(mqttTopic: String): Greenhouse?
}# Verificar que las entities tienen los campos UUID correctos
grep -n "greenhouseId: UUID" src/main/kotlin/com/apptolast/invernaderos/entities/timescaledb/entities/SensorReading.kt
grep -n "tenantId: UUID?" src/main/kotlin/com/apptolast/invernaderos/entities/timescaledb/entities/SensorReading.kt
# Resultado esperado:
# Línea XX: val greenhouseId: UUID,
# Línea YY: val tenantId: UUID? = null,Archivo: src/main/resources/application.yaml
Añadir/verificar la siguiente configuración:
spring:
mqtt:
topics:
greenhouse-multi-tenant: "GREENHOUSE/+" # Nuevo topic pattern-- Conectar a PostgreSQL metadata
PGPASSWORD="AppToLast2023%" psql -h 138.199.157.58 -p 30433 -U admin -d postgres
-- Verificar que existe tenant DEFAULT con datos migrados
SELECT
t.id,
t.name,
t.mqtt_topic_prefix,
COUNT(g.id) as num_greenhouses
FROM metadata.tenants t
LEFT JOIN metadata.greenhouses g ON g.tenant_id = t.id
WHERE t.mqtt_topic_prefix = 'DEFAULT'
GROUP BY t.id, t.name, t.mqtt_topic_prefix;
-- Resultado esperado: 1 tenant con N greenhouses (N = greenhouses existentes pre-migración)-- Crear tenant de prueba SARA
INSERT INTO metadata.tenants (
id, name, email, company_name, tax_id,
mqtt_topic_prefix, is_active, created_at, updated_at
) VALUES (
gen_random_uuid(),
'Sara Agro',
'contacto@saraagro.com',
'Sara Agrícola S.L.',
'B12345678',
'SARA',
true,
NOW(),
NOW()
) RETURNING id, name, mqtt_topic_prefix;
-- Crear greenhouse para SARA
INSERT INTO metadata.greenhouses (
id, tenant_id, name, greenhouse_code, mqtt_topic,
mqtt_publish_interval_seconds, is_active, created_at, updated_at
)
SELECT
gen_random_uuid(),
t.id,
'Invernadero Sara 01',
'SARA_01',
'GREENHOUSE/SARA',
5,
true,
NOW(),
NOW()
FROM metadata.tenants t
WHERE t.mqtt_topic_prefix = 'SARA'
RETURNING id, name, mqtt_topic;# Instalar mosquitto_pub si no está instalado
# sudo apt-get install mosquitto-clients
# Test con topic legacy (tenant DEFAULT)
mosquitto_pub -h <EMQX_BROKER> -p 1883 -u <USERNAME> -P <PASSWORD> \
-t "GREENHOUSE" \
-m '{"TEMPERATURA INVERNADERO 01":25.5,"HUMEDAD INVERNADERO 01":60.2}'
# Test con topic multi-tenant (SARA)
mosquitto_pub -h <EMQX_BROKER> -p 1883 -u <USERNAME> -P <PASSWORD> \
-t "GREENHOUSE/SARA" \
-m '{"TEMPERATURA INVERNADERO 01":23.1,"HUMEDAD INVERNADERO 01":58.7}'# Logs de la aplicación Spring Boot
docker logs -f invernaderos-api --tail=100
# Buscar mensajes de validación tenant
grep "Tenant validado" logs.txt
grep "Greenhouse encontrado" logs.txt
grep "Procesamiento completado" logs.txt
# Resultado esperado:
# Tenant validado: Cliente Migración (UUID: xxx-xxx) [para GREENHOUSE]
# Tenant validado: Sara Agro (UUID: yyy-yyy) [para GREENHOUSE/SARA]
# Greenhouse encontrado: Invernadero Sara 01 (UUID: zzz-zzz)
# ✅ Procesamiento completado - Tenant: Sara Agro (SARA), Greenhouse: ... , 22 lecturas guardadas-- Conectar a TimescaleDB
PGPASSWORD="AppToLast2023%" psql -h 138.199.157.58 -p 30432 -U admin -d postgres
-- Verificar lecturas con UUIDs correctos
SELECT
time,
sensor_id,
greenhouse_id,
tenant_id,
sensor_type,
value,
unit
FROM public.sensor_readings
ORDER BY time DESC
LIMIT 10;
-- Verificar que tenant_id NO es NULL en lecturas nuevas
SELECT COUNT(*) FROM public.sensor_readings WHERE tenant_id IS NULL;
-- Resultado: Puede haber lecturas antiguas con NULL (antes de migración V8), pero las nuevas deben tener tenant_id
-- Verificar que greenhouse_id es UUID válido
SELECT
greenhouse_id,
COUNT(*) as num_readings
FROM public.sensor_readings
WHERE time > NOW() - INTERVAL '1 hour'
GROUP BY greenhouse_id;Monitorear durante las primeras 24-48 horas:
-
Tasa de errores MQTT:
# Contar errores de validación tenant grep "Tenant no encontrado" logs.txt | wc -l # Debe ser 0 después de configuración correcta
-
Latencia de procesamiento:
-- Diferencia entre timestamp del sensor y tiempo de guardado en DB SELECT AVG(EXTRACT(EPOCH FROM (time - time))) as avg_latency_seconds FROM public.sensor_readings WHERE time > NOW() - INTERVAL '1 hour';
-
Integridad referencial:
-- Verificar que todos los greenhouse_id existen en metadata SELECT COUNT(*) FROM public.sensor_readings sr LEFT JOIN metadata.greenhouses g ON sr.greenhouse_id = g.id WHERE g.id IS NULL AND sr.time > NOW() - INTERVAL '1 hour'; -- Debe devolver 0
Configurar alertas para:
- ❌
IllegalArgumentException: Tenant no encontrado→ Indica topic MQTT no configurado - ❌
IllegalStateException: No se encontró greenhouse activo→ Indica greenhouse inactivo o no creado - ❌ Errors de conversión UUID → Indica datos corruptos en greenhouse_id
# Restaurar PostgreSQL metadata
PGPASSWORD="AppToLast2023%" pg_restore -h 138.199.157.58 -p 30433 -U admin \
-d postgres --clean --if-exists \
backup_metadata_YYYYMMDD_HHMMSS.dump
# Restaurar TimescaleDB
PGPASSWORD="AppToLast2023%" pg_restore -h 138.199.157.58 -p 30432 -U admin \
-d postgres --clean --if-exists \
backup_timescale_YYYYMMDD_HHMMSS.dump
# Revertir código a versión anterior
git checkout <commit-hash-pre-migration>
./gradlew clean build -x test
docker-compose up -d --build-- Revertir V9 (índices)
DROP INDEX IF EXISTS metadata.idx_tenants_active;
DROP INDEX IF EXISTS metadata.idx_tenants_mqtt_prefix;
-- ... (continuar con todos los índices de V9)
-- Revertir V8 (UUID migration) - NO REVERSIBLE sin pérdida de datos
-- IMPOSIBLE revertir UUID → VARCHAR sin backup
-- Revertir V7 (datos migrados)
DELETE FROM metadata.tenants WHERE mqtt_topic_prefix = 'DEFAULT';
-- Revertir V6 (tabla actuators)
DROP TABLE IF EXISTS metadata.actuators CASCADE;
-- Revertir V5 (campos sensors)
ALTER TABLE metadata.sensors DROP COLUMN IF EXISTS sensor_code;
ALTER TABLE metadata.sensors DROP COLUMN IF EXISTS tenant_id;
-- ... (continuar con todas las columnas de V5)
-- Revertir V4 (campos greenhouses)
ALTER TABLE metadata.greenhouses DROP COLUMN IF EXISTS greenhouse_code;
-- ... (continuar con todas las columnas de V4)
-- Revertir V3 (campos tenants)
ALTER TABLE metadata.tenants DROP COLUMN IF EXISTS company_name;
-- ... (continuar con todas las columnas de V3)- ✅ Migración ejecutada en staging sin errores
- ✅ Tests manuales pasados (5/5 tests de Fase 3)
- ✅ Backups completos realizados y verificados
- ✅ Equipo de soporte notificado de la ventana de mantenimiento
- ✅ Plan de rollback documentado y probado
- ✅ Monitoring/alertas configuradas
- ✅ Variables de entorno actualizadas (si aplica)
- ✅ Documentación de API actualizada (si hay cambios en endpoints)
- ✅ Anunciar downtime a usuarios (si aplica)
- ✅ Detener aplicación (
docker-compose down) - ✅ Ejecutar backups inmediatos
- ✅ Ejecutar migraciones SQL (V3-V9 en orden)
- ✅ Validar migraciones con queries de verificación
- ✅ Desplegar código actualizado (
docker-compose up -d --build) - ✅ Monitorear logs en tiempo real (
docker logs -f invernaderos-api) - ✅ Ejecutar tests de humo (enviar mensajes MQTT de prueba)
- ✅ Verificar que sistema procesa mensajes MQTT correctamente
- ✅ Verificar que datos se guardan en TimescaleDB con UUIDs
- ✅ Verificar WebSocket broadcasting (si aplica)
- ✅ Monitorear métricas críticas durante 1 hora
- ✅ Confirmar 0 errores en logs
- ✅ Anunciar fin de mantenimiento
# PostgreSQL metadata (port 30433)
PGPASSWORD="AppToLast2023%" psql -h 138.199.157.58 -p 30433 -U admin -d postgres
# TimescaleDB (port 30432)
PGPASSWORD="AppToLast2023%" psql -h 138.199.157.58 -p 30432 -U admin -d postgres-- Listar todos los tenants con su MQTT prefix
SELECT id, name, mqtt_topic_prefix, is_active FROM metadata.tenants;
-- Listar greenhouses por tenant
SELECT
t.name as tenant_name,
t.mqtt_topic_prefix,
g.name as greenhouse_name,
g.mqtt_topic,
g.is_active
FROM metadata.tenants t
LEFT JOIN metadata.greenhouses g ON g.tenant_id = t.id
ORDER BY t.name, g.name;
-- Contar lecturas por tenant (últimas 24h)
SELECT
t.name as tenant_name,
COUNT(*) as num_readings
FROM public.sensor_readings sr
JOIN metadata.tenants t ON sr.tenant_id = t.id
WHERE sr.time > NOW() - INTERVAL '24 hours'
GROUP BY t.name
ORDER BY num_readings DESC;# Ver logs en tiempo real
docker logs -f invernaderos-api --tail=100
# Filtrar errores críticos
docker logs invernaderos-api 2>&1 | grep "ERROR"
# Buscar mensajes de validación tenant
docker logs invernaderos-api 2>&1 | grep "Tenant validado"
# Ver uso de memoria/CPU del contenedor
docker stats invernaderos-apiEn caso de problemas durante la migración:
- STOP INMEDIATAMENTE si se detectan errores críticos
- Revisar logs completos:
docker logs invernaderos-api > migration_error.log - Ejecutar queries de diagnóstico (sección Referencia Rápida)
- Si es necesario, ejecutar ROLLBACK (sección anterior)
- Contactar equipo de desarrollo con:
- Logs completos (
migration_error.log) - Queries de diagnóstico ejecutadas
- Versión de código/commit hash
- Hora exacta del error
- Logs completos (
Esta migración ha sido diseñada para ser segura, reversible y sin pérdida de datos. Sin embargo, como cualquier operación de este tipo:
⚠️ NUNCA ejecutar en producción sin haber probado en staging⚠️ SIEMPRE tener backups completos antes de empezar⚠️ VERIFICAR cada paso antes de continuar con el siguiente- ✅ COMUNICAR al equipo y usuarios sobre ventanas de mantenimiento
Fecha de creación de esta guía: 2025-11-16 Versión: 1.0 Autor: Claude Code (AI Assistant) Revisado por: [PENDIENTE - completar después de revisión humana]
¿Listo para comenzar? 🚀 Sigue los pasos en orden y marca cada checkbox al completarlo. ¡Buena suerte!