Documento de referencia técnica para la implementación de los experimentos de seguridad (E01) y disponibilidad (E02) del Reto 2 de ARTI4109.
1. STACK TECNOLÓGICO
Componentes principales
Lenguaje y Framework
├── Java 21 (JDK 21+)
├── Spring Boot 3
└── Spring Web, Spring AMQP, Spring JDBC
Infraestructura
├── PostgreSQL 17 (base de datos)
├── RabbitMQ 4.1 (bróker de mensajes)
├── Docker & Docker Compose (orquestación)
└── Colima (para macOS)
Observabilidad
├── Micrometer (métricas)
├── Prometheus (recolección)
└── Grafana (visualización)
Pruebas de carga
├── k6 (generador de carga)
└── Arribo aleatorio (1 pedido/seg, 10 consultas/seg)
Herramientas de build
- Gradle 8.x: Multi-proyecto, task graph
- Docker Buildx: Imágenes multi-stage
- Make: Orquestación de comandos
2. ESTRUCTURA DE PAQUETES Y PROYECTOS
Árbol de servicios
services/
├── comun/ # Librería compartida (no es Boot app)
│ ├── src/main/java/co/mati/reto2/comun/
│ │ ├── Registro.java # Anotador de eventos para veredicto
│ │ ├── Bus.java # Abstracción RabbitMQ (constantes de colas)
│ │ └── Http.java # Factory de RestClient reutilizable
│ └── build.gradle.kts
│
├── sesiones/ # E01: Gestor de sesión (detección)
│ └── SesionController.java # POST /sesiones
│
├── usuario-no-valido/ # E01: Verificador de dispositivo
│ └── VerificadorDeDispositivo.java # Consumer RabbitMQ
│
├── monitor/ # E02: Sondeo de etapas (detección)
│ └── MonitorDeLaCadena.java # @Scheduled, sondea /salud
│
├── ventas/ # E02: Coordinador (reacción)
│ ├── EnvioALaDeadLetterQueue.java # POST /fallas/etapa
│ ├── Despachador.java # Gestor de pedidos
│ └── PedidoController.java # POST /pedidos
│
├── etapa/ # 3 instancias: facturación, inventario, despacho
│ ├── TrabajoController.java # POST /trabajo (procesa pedido)
│ └── ReciboDeEtapa.java # GET /salud (punto de salud)
│
├── logistica/
├── notificador/
├── receptor-sms/
├── onboarding/
├── auditor-cola/
└── ... (11 micros totales)
load/ # Scripts de pruebas
├── experimento.sh # Orquestador maestro (bash)
├── experimento.ps1 # Versión PowerShell
├── e2e.sh # Prueba de punta a punta
├── smoke.sh # Humo (~7 min)
├── plan.tsv # Matriz de corridas (T, N, variantes)
└── evidencias-captura.sh # Captura de pantallas Grafana
deploy/ # Configuración e infraestructura
├── docker-compose.yml # 12 micros + PostgreSQL + RabbitMQ + Prometheus + Grafana
├── Dockerfile # Multi-stage: compilación → runtime
├── postgres/
│ ├── 01-esquema.sql # DDL: tablas, vistas
│ ├── 02-semilla.sql # Datos iniciales
│ └── 03-vistas.sql # Cruces para veredicto
├── observabilidad/
│ ├── prometheus.yml # Scrape targets
│ └── grafana/dashboards/
│ ├── e01.json # Dashboard E01
│ ├── e02.json # Dashboard E02
│ ├── evidencias-e01.json # E01 con trazas
│ └── evidencias-e02.json # E02 con trazas
└── inyector/
└── inyectar.sh # Inyecta fallos (stop -STOP, congelamiento)
analisis/ # Post-processing de resultados
├── e01.sql # Veredicto E01 (cruces entrada-salida)
├── e02.sql # Veredicto E02 (idempotencia, tiempos)
└── resultados/
├── e01-s1-20261012-*/
│ ├── k6-cadena.txt # Salida de k6
│ ├── tablero.txt # Capturas Grafana
│ ├── plan.tsv # Parámetros usados
│ └── veredicto.txt # PASS/FAIL por criterio
└── ...
Configuración de build
build.gradle.kts (raíz):
// Java 21 obligatorio
extensions.configure<JavaPluginExtension> {
toolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
// Cada micro Spring Boot genera app.jar (nombre fijo)
tasks.named<BootJar>("bootJar") {
archiveFileName.set("app.jar")
}
services/comun/build.gradle.kts: Sin Spring Boot, solo librería.
3. PATRONES DE DISEÑO IMPLEMENTADOS
3.1 E01 - Detección (Seguridad: ASR-1)
Componentes
- Sesiones (8081): Abre sesión y publica evento
- Usuario-No-Válido (8082): Verificador que compara huella
- Notificador (8097): Emite alerta a seguridad
Flujo técnico
1. Cliente solicita: POST /sesiones
{
"sesionId": "s-uuid",
"vendedorId": "v-123",
"password": "...",
"dispositivoId": "d-456"
}
2. SesionController.abrir() [Sesiones]
├─ jdbc.queryForList(SELECT password_hash ...)
├─ Valida: Hash.sha256(password) == BD_hash
├─ SI VÁLIDA:
│ ├─ INSERT INTO operacion.sesion(id, vendedor_id, dispositivo_id, abierta_en)
│ ├─ registro.anotar(NOW(), "sesion.abierta", sesionId, ...) ← t0 E01
│ ├─ bus.convertAndSend("seguridad", "sesion-abierta", SesionAbierta{...})
│ └─ 201 Created + sesionId
└─ SI NO: 401 Unauthorized
3. VerificadorDeDispositivo [Usuario-No-Válido]
Consumer RabbitMQ en cola "verificador.sesiones"
├─ Recibe: SesionAbierta{sesionId, vendedorId, dispositivoId, abiertaEn}
├─ Consulta: SELECT * FROM operacion.dispositivo
│ WHERE id = :dispositivoId AND registrado = true
├─ SI NO EXISTE:
│ ├─ registro.anotar(NOW(), "aviso.emitido", sesionId, ...) ← t1 E01
│ ├─ bus.convertAndSend("notificador", "alertas", Alerta{...})
│ └─ log.warn("dispositivo no registrado: {}", dispositivoId)
└─ SI EXISTE: noop (dispositivo registrado)
4. Notificador [8097]
Consumer RabbitMQ en cola "notificador.alertas"
├─ Recibe: Alerta{vendedorId, dispositivoId, ...}
├─ Envía: email/SMS (simulado)
└─ registro.anotar(NOW(), "alerta.notificada", ...)
Código clave: SesionController.java
@PostMapping("/sesiones")
ResponseEntity<Map<String, String>> abrir(@RequestBody Apertura a) {
// Validar credencial
List<String> hash = jdbc.queryForList(
"SELECT password_hash FROM operacion.vendedor WHERE id = ?",
String.class, a.vendedorId());
if (hash.isEmpty() || !hash.getFirst().equals(Hash.sha256(a.password()))) {
rechazos.increment();
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
// Guardar sesión en BD
Instant abierta = Instant.now();
jdbc.update("INSERT INTO operacion.sesion (id, vendedor_id, dispositivo_id, abierta_en) " +
"VALUES (?, ?, ?, ?)",
a.sesionId(), a.vendedorId(), a.dispositivoId(), Timestamp.from(abierta));
// t0: Anotar evento
registro.anotar(Instant.now(), "sesion.abierta", a.sesionId(), null, null,
Map.of("vendedorId", a.vendedorId(), "dispositivoId", a.dispositivoId()));
// Publicar evento para Verificador
aperturas.increment();
bus.convertAndSend(Bus.SEGURIDAD, Bus.SESION_ABIERTA,
new SesionAbierta(a.sesionId(), a.vendedorId(), a.dispositivoId(), abierta.toString()));
return ResponseEntity.status(HttpStatus.CREATED).body(Map.of("sesionId", a.sesionId()));
}
Métricas clave
sesiones.aperturas: Counter de sesiones abiertassesiones.credencial.rechazada: Counter de fallos de autenticación- Prometheus scrape:
http://localhost:8081/actuator/prometheus
Criterios de aceptación (E01)
- S1: 50 sesiones no registradas → 50 avisos en ≤2s cada uno
- S2: 100 cambios legítimos → ≤1 falso aviso
- S3: 20 aperturas <1s post-registro → 0 avisos
3.2 E02 - Detección y Reacción (Disponibilidad: ASR-3 + ASR-4)
Componentes
- Monitor (8095): Sondea salud de etapas
- Ventas (8090): Coordinador, reacciona a fallos
- Etapas (8091-8093): Facturación, inventario, despacho
Fase 1: DETECCIÓN
MonitorDeLaCadena.java - Sondeo periódico
@Component
class MonitorDeLaCadena {
private final long tMs; // SONDEO_T_MS: período en ms
private final int n; // SONDEO_N: fallos consecutivos
private final long umbralMs; // UMBRAL_ATASCO_MS: tiempo máximo sin terminar
@PostConstruct
void arrancar() {
// Programar sondeo cada T milisegundos
ciclo.scheduleAtFixedRate(this::sondear, tMs, tMs, TimeUnit.MILLISECONDS);
}
private void sondear() {
// Lanzar sondeo en paralelo para 3 etapas (virtual threads)
Map<String, Future<Sondeo>> respuestas = new LinkedHashMap<>();
etapas.forEach((etapa, cliente) ->
respuestas.put(etapa, trabajo.submit(() -> sondeo(cliente)))
);
// Esperar respuestas (con timeout < T)
respuestas.forEach((etapa, futuro) -> {
Sondeo sondeo;
try {
sondeo = futuro.get(); // timeout integrado en cliente HTTP
} catch (Exception ex) {
sondeo = Sondeo.sinRespuesta(ex.getClass().getSimpleName());
}
// Registrar conteo de fallos
aplicar(etapa, sondeo.causa());
// Si etapa respondió, procesar pedidos atascados
if (sondeo.causa() == null) {
atascados(etapa, sondeo.atascados());
}
});
}
private Sondeo sondeo(RestClient cliente) {
try {
// GET /salud?mayorQueMs=U
Map<?, ?> salud = cliente.get()
.uri("/salud?mayorQueMs={u}", umbralMs)
.retrieve()
.body(Map.class);
// Extraer lista de pedidos atascados
List<String> atascados = new ArrayList<>();
if (salud != null && salud.get("atascados") instanceof List<?> lista) {
for (Object p : lista) {
if (p instanceof Map<?, ?> pedido) {
atascados.add(pedido.get("pedidoId").toString());
}
}
}
return new Sondeo(null, atascados); // null = sin error
} catch (Exception ex) {
return Sondeo.sinRespuesta(ex.getClass().getSimpleName());
}
}
private void aplicar(String etapa, String causa) {
CuentaDeSondeos cuenta = cuentas.get(etapa);
if (causa != null) {
cuenta.registrarFallo();
} else {
cuenta.resetear();
}
// Si suma N fallos → notificar al Coordinador
if (cuenta.detenida()) {
notificarCoordinador(etapa);
}
}
private void notificarCoordinador(String etapa) {
ventas.post()
.uri("/fallas/etapa")
.body(new FallaDeEtapa(etapa))
.retrieve()
.toBodilessEntity();
}
}
record Sondeo(String causa, List<String> atascados) {
static Sondeo sinRespuesta(String causa) {
return new Sondeo(causa, List.of());
}
}
Punto de salud: ReciboDeEtapa.java
@GetMapping("/salud")
Map<String, Object> salud(@RequestParam(defaultValue = "0") long mayorQueMs) {
List<Map<String, String>> atascados = new ArrayList<>();
if (mayorQueMs > 0) {
// Encontrar pedidos que llevan más de U sin terminar
List<String> pedidosLargos = jdbc.queryForList("""
SELECT pedido_id FROM operacion.pedido_etapa
WHERE etapa = ? AND estado = 'EN_CURSO'
AND EXTRACT(EPOCH FROM (NOW() - enviado_en)) * 1000 > ?
""", String.class, etapa, mayorQueMs);
for (String id : pedidosLargos) {
atascados.add(Map.of("pedidoId", id));
}
}
return Map.of(
"estado", "UP",
"etapa", etapa,
"atascados", atascados
);
}
Parámetros variacionales (sin código)
Vienen de variables de entorno, inyectadas en tiempo de startup:
| Parámetro | Variable entorno | Significado | Default |
|---|---|---|---|
| T | SONDEO_T_MS |
Período de sondeo (ms) | 2000 |
| N | SONDEO_N |
Fallos consecutivos para declarar detenida | 3 |
| Espera | SONDEO_ESPERA_MS |
Timeout HTTP por sondeo | 500 |
| U | UMBRAL_ATASCO_MS |
Tiempo máximo sin terminar (ms) | 15000 |
En experimento.sh:
SONDEO_T_MS="$T" SONDEO_N="$N" SONDEO_ESPERA_MS="$ESPERA_MS" \
UMBRAL_ATASCO_MS="$UMBRAL_ATASCO_MS" \
docker compose up -d --force-recreate monitor
Fase 2: REACCIÓN
EnvioALaDeadLetterQueue.java - Coordinador
@RestController
class EnvioALaDeadLetterQueue {
static final String ETAPA_CAIDA = "ETAPA_CAIDA";
static final String PEDIDO_ATASCADO = "PEDIDO_ATASCADO";
@PostMapping("/fallas/etapa")
ResponseEntity<Void> etapaCaida(@RequestBody FallaDeEtapa f) {
// Async, sin bloquear respuesta
AtomicBoolean ocupado = enviando.computeIfAbsent(f.etapa(), e -> new AtomicBoolean());
if (ocupado.compareAndSet(false, true)) {
trabajo.submit(() -> {
try {
enviarPendientes(f.etapa());
} finally {
ocupado.set(false);
}
});
}
return ResponseEntity.accepted().build();
}
private void enviarPendientes(String etapa) {
// Listar todos los pedidos en curso en esa etapa
List<Map<String, Object>> pendientes = jdbc.queryForList("""
SELECT pedido_id, enviado_en FROM operacion.pedido_etapa
WHERE etapa = ? AND estado = 'EN_CURSO' ORDER BY enviado_en
""", etapa);
for (Map<String, Object> p : pendientes) {
enviar(
(String) p.get("pedido_id"),
((Timestamp) p.get("enviado_en")).toInstant(),
etapa,
ETAPA_CAIDA
);
}
}
private void enviar(String pedidoId, Instant enviadoEn, String etapa, String causa) {
Instant detectado = Instant.now();
// IDEMPOTENCIA (ADR-005): clave única (pedido, etapa, intento)
// ON CONFLICT DO NOTHING evita reintentos duplicados
int nueva = jdbc.update("""
INSERT INTO operacion.senal (pedido_id, etapa, intento, detectada_en)
VALUES (?, ?, ?, ?)
ON CONFLICT DO NOTHING
""", pedidoId, etapa, INTENTO, Timestamp.from(detectado));
if (nueva == 0) {
// Ya fue procesado, no reintentar
return;
}
String clave = pedidoId + ":" + etapa + ":" + INTENTO;
try {
// Enviar con confirmación (NR-004a de ADR-004)
CorrelationData correlacion = new CorrelationData(clave);
bus.convertAndSend(
Bus.CADENA,
Bus.PEDIDO_DETENIDO,
new PedidoDetenido(
pedidoId, etapa, causa, INTENTO,
enviadoEn.toString(), detectado.toString(),
Duration.between(enviadoEn, detectado).toMillis()
),
m -> {
m.getMessageProperties().setMessageId(clave);
m.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT);
return m;
},
correlacion
);
// ESPERAR ACK DEL BRÓKER (5s timeout)
CorrelationData.Confirm confirmacion =
correlacion.getFuture().get(ESPERA_CONFIRMACION_MS, TimeUnit.MILLISECONDS);
if (!confirmacion.isAck()) {
throw new IllegalStateException(
"el bróker rechazó el mensaje: " + confirmacion.getReason()
);
}
} catch (Exception ex) {
// Sin confirmación: rollback, reintentar en próximo ciclo
log.warn("sin confirmación para {}, se reintenta en el próximo aviso: {}",
clave, ex.getMessage());
jdbc.update("DELETE FROM operacion.senal WHERE pedido_id = ? AND etapa = ? AND intento = ?",
pedidoId, etapa, INTENTO);
return;
}
// CONFIRMADA: actualizar timestamp y anotar evento
Instant confirmada = Instant.now();
jdbc.update(
"UPDATE operacion.senal SET confirmada_en = ? WHERE pedido_id = ? AND etapa = ? AND intento = ?",
Timestamp.from(confirmada), pedidoId, etapa, INTENTO
);
// t1 de E02: evento de éxito
registro.anotar(
confirmada,
"pedido.encolado",
null,
pedidoId,
etapa,
Map.of(
"intento", INTENTO,
"causa", causa,
"transcurridoMs", Duration.between(enviadoEn, confirmada).toMillis()
)
);
log.info("pedido {} → Dead-Letter-Queue · falta {} · causa {} · detenido {} ms",
pedidoId, etapa, causa,
Duration.between(enviadoEn, confirmada).toMillis());
metricas.counter("ventas.pedidos.encolados", "etapa", etapa).increment();
}
}
record FallaDeEtapa(String etapa) {}
record PedidoDetenido(String pedidoId, String etapa, String causa, int intento,
String enviadoEn, String detectadoEn, long transcurridoMs) {}
Ruta del evento (cadena de componentes)
Monitor (8095)
├─ scheduleAtFixedRate() cada T ms
├─ GET /salud?mayorQueMs=U (etapa)
├─ SI timeout || N fallos: aplicar(etapa, "TimeoutException")
└─ POST http://ventas:8080/fallas/etapa {etapa}
Ventas (8090) - EnvioALaDeadLetterQueue
├─ SELECT pedido_etapa WHERE etapa='X' AND estado='EN_CURSO'
├─ FOR EACH pedido:
│ ├─ INSERT senal(pedido, etapa, 1) ON CONFLICT DO NOTHING
│ ├─ RabbitMQ.convertAndSend("cadena", "pedido.detenido", {...})
│ ├─ CorrelationData: esperar ACK (5s)
│ ├─ SI ACK: UPDATE senal.confirmada_en, anotar "pedido.encolado" (t1)
│ └─ SI NO ACK: DELETE senal, log.warn, reintenta en próximo ciclo
└─ Métrica: ventas.pedidos.encolados[etapa]++
RabbitMQ (bróker)
├─ Recibe: cadena.pedido-detenido
├─ Routea a: dead-letter-queue (T11, durable)
└─ ACK: CorrelationData.Confirm.isAck() = true
Dead-Letter-Queue (T11)
└─ Nadie consume (ASR-4 está fuera de alcance E02)
4. REGISTRO DE EVENTOS Y VEREDICTO
Tabla central: operacion.evento
CREATE TABLE operacion.evento (
id BIGSERIAL PRIMARY KEY,
cuando TIMESTAMP NOT NULL DEFAULT NOW(),
evento VARCHAR(50) NOT NULL, -- ej: "sesion.abierta", "pedido.encolado"
sesion_id VARCHAR(50), -- para E01
pedido_id VARCHAR(50), -- para E02
etapa VARCHAR(20), -- facturacion, inventario, despacho
datos JSONB -- parámetros extra
);
CREATE INDEX ON operacion.evento(evento);
CREATE INDEX ON operacion.evento(sesion_id);
CREATE INDEX ON operacion.evento(pedido_id);
Anotación desde código
Cada componente llama:
registro.anotar(
Instant.now(), // timestamp
"evento.nombre", // evento único
sesionId, // null si no aplica
pedidoId, // null si no aplica
etapa, // null si no aplica
Map.of( // datos contextuales
"key1", value1,
"key2", value2
)
);
Veredicto E01: analisis/e01.sql
-- Cruce entrada → salida
SELECT
s.sesion_id,
s.cuando AS t0_apertura,
s.datos->>'vendedorId' AS vendedor,
s.datos->>'dispositivoId' AS dispositivo,
v.cuando AS t1_aviso,
EXTRACT(EPOCH FROM (v.cuando - s.cuando)) * 1000 AS demora_ms,
CASE
WHEN v.id IS NULL THEN 'SIN_AVISO'
WHEN EXTRACT(EPOCH FROM (v.cuando - s.cuando)) * 1000 > 2000 THEN 'FUERA_PLAZO'
ELSE 'OK'
END AS estado
FROM operacion.evento s
LEFT JOIN operacion.evento v
ON s.sesion_id = v.sesion_id
AND v.evento = 'aviso.emitido'
WHERE s.evento = 'sesion.abierta'
AND s.datos->>'dispositivoId' NOT IN (SELECT id FROM operacion.dispositivo WHERE registrado)
ORDER BY s.cuando;
Criterios:
- S1: 50 sesiones no registradas → 50 avisos cada uno ≤2s ✓
- S2: 100 cambios legítimos → ≤1 falso aviso ✓
- S3: 20 aperturas <1s post-registro → 0 avisos ✓
Veredicto E02: analisis/e02.sql
-- Idempotencia: un pedido NO debe aparecer >1 en dead-letter-queue
SELECT
pedido_id,
COUNT(*) AS veces_encolado,
CASE
WHEN COUNT(*) > 1 THEN 'DUPLICADO'
WHEN COUNT(*) = 1 THEN 'OK'
ELSE 'PERDIDO'
END AS estado
FROM operacion.evento
WHERE evento = 'pedido.encolado'
GROUP BY pedido_id
HAVING COUNT(*) > 1
ORDER BY veces_encolado DESC;
-- Tiempos: demora desde enviado_en hasta pedido.encolado
SELECT
pedido_id,
etapa,
MAX(CAST(datos->>'transcurridoMs' AS BIGINT)) AS demora_ms,
CASE
WHEN MAX(CAST(datos->>'transcurridoMs' AS BIGINT)) <= 6400 THEN 'OK'
ELSE 'FUERA_PLAZO'
END AS estado
FROM operacion.evento
WHERE evento = 'pedido.encolado'
GROUP BY pedido_id, etapa
ORDER BY demora_ms DESC;
Criterios:
- D1 (sin fallos): baseline de rendimiento
- D2 (caídas de etapa): todos los pedidos en Dead-Letter-Queue ≤6.4s
- D3 (congelamiento): detectar y enviar pedidos atascados
- D5 (entregas repetidas): cero duplicados en Dead-Letter-Queue
5. LANZAMIENTO DE PRUEBAS
Orquestador maestro: load/experimento.sh
#!/usr/bin/env bash
# Parámetros globales
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
COMPOSE=(docker compose -f "$ROOT/deploy/docker-compose.yml")
PLAN="$ROOT/load/plan.tsv" # Matriz de variantes (T, N, etc.)
RESULTADOS="$ROOT/analisis/resultados"
STAMP="$(date +%Y%m%d-%H%M%S)"
# k6 → Prometheus
export K6_PROMETHEUS_RW_SERVER_URL="http://localhost:9090/api/v1/write"
export K6_PROMETHEUS_RW_PUSH_INTERVAL="5s"
# Validación: batería
if pmset -g batt | grep -q "Battery Power"; then
echo "El equipo está con batería. Conéctalo a la corriente."
exit 1
fi
# Ciclo por cada línea de plan.tsv
for linea in $(tail -n +2 "$PLAN"); do
EXPERIMENTO=$(echo "$linea" | cut -f1)
T=$(echo "$linea" | cut -f2)
N=$(echo "$linea" | cut -f3)
log "Corrida: $EXPERIMENTO, T=$T, N=$N"
# 1. PREPARAR
preparar "$EXPERIMENTO" "$T" "$N"
# 2. CALENTAR (5 min, descartados)
k6 run $K6_OUT --duration 5m load/carga.js
# 3. MEDIR (según fase)
case "$EXPERIMENTO" in
E01)
# S1: 50 sesiones no registradas, medir avisos ≤2s
k6 run $K6_OUT load/e01.js
;;
E02)
# D1: sin fallos
# D2: detener etapas X veces
# D3: congelar pedidos
# D5: entregas repetidas
inyector -fases D1 D2 D3 D5 -plan "$linea"
;;
esac
# 4. CERRAR (20s más, para salidas tardías)
k6 run $K6_OUT --duration 20s load/carga.js
# 5. VEREDICTO
veredicto "$EXPERIMENTO" "$STAMP"
done
Ciclo de una corrida
preparar(E02, T=2000, N=3)
├─ docker compose up -d # Levanta 12 micros + infra
├─ esperar_salud 8081 8082 ... 8097 # Loop 90s, curl /actuator/health
├─ sql: SELECT operacion.reiniciar_e02() # Truncate tablas
├─ Vaciar colas RabbitMQ
└─ SONDEO_T_MS=2000 SONDEO_N=3 docker compose up -d monitor
calentar()
├─ k6 run --duration 5m load/carga.js
└─ Esto NO entra en veredicto
medir()
├─ E02 D1: k6 con carga normal (baseline)
├─ E02 D2: docker stop etapa-X → Monitor detecta → Ventas reacciona
├─ E02 D3: inyector congela pedidos → Monitor detecta pedidos.atascados
└─ E02 D5: entregas repetidas (inyectar)
cerrar()
├─ k6 run --duration 20s load/carga.js
└─ Tiempo para que lleguen respuestas tardías
veredicto()
├─ psql: \i analisis/e02.sql
├─ Genera: analisis/resultados/e02-...-TIMESTAMP/veredicto.txt
└─ PASS/FAIL por criterio
Validación de corrida
Una corrida es INVÁLIDA si:
HUECO_MAX_S=30 # Plazo máximo entre dos pedidos (carga de fondo 1/s)
# Hueco > 30s → máquina se congeló o suspendió
if [[ $(last_arrival_gap) > 30 ]]; then
log "Corrida contaminada: reintentando..."
REINTENTOS=$((REINTENTOS - 1))
continue
fi
# k6 > 1% fallos
if k6_error_rate > 1%; then
exit 1
fi
6. TOPOLOGÍA DE CONTENEDORES
deploy/docker-compose.yml
version: "3.8"
name: reto2
services:
# --- Infraestructura -------------------------------------------------------
postgres:
image: postgres:17-alpine
ports: ["5433:5432"]
volumes:
- ./postgres/01-esquema.sql:/docker-entrypoint-initdb.d/01-esquema.sql:ro
- ./postgres/02-semilla.sql:/docker-entrypoint-initdb.d/02-semilla.sql:ro
- ./postgres/03-vistas.sql:/docker-entrypoint-initdb.d/03-vistas.sql:ro
- datos:/var/lib/postgresql/data
rabbitmq:
image: rabbitmq:4.1-management-alpine
ports: ["15672:15672"]
environment:
RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS: "+sbwt none" # Sin busy-wait
prometheus:
image: prom/prometheus:latest
ports: ["9090:9090"]
volumes:
- ./deploy/observabilidad/prometheus.yml:/etc/prometheus/prometheus.yml:ro
grafana:
image: grafana/grafana:latest
ports: ["3000:3000"]
volumes:
- ./deploy/observabilidad/grafana/dashboards/:/etc/grafana/provisioning/dashboards/:ro
# --- E01 -------------------------------------------------------------------
sesiones:
image: reto2/sesiones
build: { context: .., dockerfile: deploy/Dockerfile, args: { SERVICE: sesiones } }
ports: ["8081:8080"]
environment: &env
DB_URL: jdbc:postgresql://postgres:5432/reto2
RABBIT_HOST: rabbitmq
usuario-no-valido:
image: reto2/usuario-no-valido
ports: ["8082:8080"]
environment: *env
notificador:
image: reto2/notificador
ports: ["8097:8080"]
environment: *env
# --- E02 -------------------------------------------------------------------
monitor:
image: reto2/monitor
ports: ["8095:8080"]
environment:
<<: *env
SONDEO_T_MS: ${SONDEO_T_MS:-2000}
SONDEO_N: ${SONDEO_N:-3}
SONDEO_ESPERA_MS: ${SONDEO_ESPERA_MS:-500}
UMBRAL_ATASCO_MS: ${UMBRAL_ATASCO_MS:-15000}
ventas:
image: reto2/ventas
ports: ["8090:8080"]
environment: *env
etapa-facturacion:
image: reto2/etapa
build: { context: .., dockerfile: deploy/Dockerfile, args: { SERVICE: etapa } }
ports: ["8091:8080"]
environment: &etapa_env
<<: *env
ETAPA: facturacion
etapa-inventario:
image: reto2/etapa
ports: ["8092:8080"]
environment:
<<: *etapa_env
ETAPA: inventario
etapa-despacho:
image: reto2/etapa
ports: ["8093:8080"]
environment:
<<: *etapa_env
ETAPA: despacho
# ... más micros
volumes:
datos:
prometheus:
Puertos del host
8081 sesiones
8082 usuario-no-valido (Verificador)
8083 receptor-sms
8084 onboarding
8090 ventas
8091 etapa-facturacion
8092 etapa-inventario
8093 etapa-despacho
8094 logistica
8095 monitor
8096 auditor-cola
8097 notificador
3000 Grafana
5433 PostgreSQL (5432 interno)
9090 Prometheus
15672 RabbitMQ Management
7. OBSERVABILIDAD EN VIVO
Métricas de aplicación (Micrometer)
# E01
sesiones.aperturas (Counter)
sesiones.credencial.rechazada (Counter)
# E02 - Monitor
monitor.etapa.detenida[etapa] (Gauge: 0/1)
monitor.sondeos.fallidos_seguidos (Gauge)
monitor.config.t_ms (Gauge: parámetro T)
monitor.config.n (Gauge: parámetro N)
monitor.config.umbral_ms (Gauge: parámetro U)
# E02 - Ventas
ventas.pedidos.encolados[etapa] (Counter)
# k6 (remote write)
http_reqs (count, rate)
http_req_duration (ms, p50/p95/p99)
http_req_failed (count, %)
Dashboards Grafana
- e01.json: Aperturas/s, avisos/s, demora (ms) por fase
- e02.json: Pedidos en cola, etapas detenidas, demora detección
- evidencias-e01.json: Trazas con anotaciones de caída
- evidencias-e02.json: Trazas con caídas inyectadas
8. PATRONES DE RESILIENCIA Y GARANTÍAS
IDEMPOTENCIA (ADR-005)
Problema: Si el bróker ACK se pierde, Ventas reintenta → duplicado
Solución: Clave única (pedido, etapa, intento)
INSERT INTO operacion.senal (pedido_id, etapa, intento, detectada_en)
VALUES (?, ?, ?, ?)
ON CONFLICT DO NOTHING ← Ignora reintentos
Status:
├─ 0 filas → Ya fue procesado → return (noop)
└─ 1 fila → Nuevo → procesar y publicar
ENTREGA GARANTIZADA (NR-004a)
Monitor notifica → Ventas publica con CorrelationData
↓
bus.convertAndSend(..., correlacion)
↓
Espera ack: correlacion.getFuture().get(5000 ms)
↓
SI ack: UPDATE senal.confirmada_en → éxito
SI NO: DELETE senal → reintenta en próximo ciclo
DETECTABILIDAD
Cada evento entra en tabla operacion.evento:
- Timestamp exacto
- Sesión o pedido (identificador único)
- Contexto (etapa, vendedor, dispositivo)
- Datos (demoras, causas)
Veredicto: SQL que cruza entrada → salida
- t0 sesion.abierta
- t1 aviso.emitido
- demora = t1 - t0
- PASS si ≤ 2s y único por sesión
9. CONFIGURACIÓN DE BUILD
Gradle multi-proyecto
build.gradle.kts (raíz)
├── Declara plugins: Spring Boot, dependency-management
├── Configura Java 21 para todos los subprojectos
└── Define repos y dependencias comunes
services/comun/build.gradle.kts
├── No aplica Spring Boot
└── Define Registro.java, Bus.java, Http.java
services/sesiones/build.gradle.kts
├── Aplica Spring Boot
├── Hereda Java 21
├── Importa BOM de Spring Boot
└── Genera app.jar → /services/sesiones/build/libs/app.jar
... (similar para cada micro)
Docker: Multi-stage build
# Etapa 1: Compilación
FROM eclipse-temurin:21-jdk AS builder
COPY . /src
WORKDIR /src
RUN ./gradlew build -x test
# Etapa 2: Runtime
FROM eclipse-temurin:21-jre
ARG SERVICE
COPY --from=builder /src/services/${SERVICE}/build/libs/app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
Invocación:
docker build --build-arg SERVICE=sesiones -t reto2/sesiones .
docker build --build-arg SERVICE=monitor -t reto2/monitor .
10. SUMARIO: FLUJO COMPLETO E01
1. k6: POST /sesiones {sesionId, vendedorId, password, dispositivoId}
2. SesionController:
├─ Valida password_hash vs BD
├─ INSERT operacion.sesion
├─ anotar("sesion.abierta") ← t0
└─ publish("seguridad/sesion-abierta")
3. RabbitMQ: "seguridad" exchange → "verificador.sesiones" queue
4. VerificadorDeDispositivo:
├─ SELECT dispositivo WHERE id=? AND registrado=true
├─ SI NO EXISTS: anotar("aviso.emitido") ← t1
└─ publish("notificador/alertas")
5. RabbitMQ: "notificador" exchange → "notificador.alertas" queue
6. Notificador:
├─ Email/SMS (simulado)
└─ anotar("alerta.notificada")
7. Veredicto SQL:
SELECT s, v, (v.cuando - s.cuando) as demora
FROM evento s LEFT JOIN evento v
WHERE s.evento='sesion.abierta' AND v.evento='aviso.emitido'
AND dispositivoId NOT IN (SELECT FROM dispositivo WHERE registrado)
PASS: 50 avisos cada uno ≤2s, ≤1 falso por 100 legítimos
11. SUMARIO: FLUJO COMPLETO E02
1. Monitor.scheduleAtFixedRate(T ms):
├─ GET /salud?mayorQueMs=U (etapa-facturacion, inventario, despacho) en paralelo
├─ SI timeout: cuenta.registrarFallo()
├─ SI N fallos: aplicar(ETAPA_CAIDA)
└─ POST /fallas/etapa {etapa}
2. EnvioALaDeadLetterQueue:
├─ SELECT pedido_etapa WHERE etapa=? AND estado='EN_CURSO'
├─ FOR pedido:
│ ├─ INSERT senal ON CONFLICT DO NOTHING
│ ├─ RabbitMQ.convertAndSend() con CorrelationData
│ ├─ Espera ack (5s)
│ ├─ SI ack: UPDATE senal.confirmada_en, anotar("pedido.encolado") ← t1
│ └─ SI NO: DELETE senal, reintenta próximo ciclo
└─ Métrica: ventas.pedidos.encolados++
3. RabbitMQ: "cadena" exchange → "dead-letter-queue" (T11)
4. Veredicto SQL:
SELECT pedido_id, COUNT(*) FROM evento WHERE evento='pedido.encolado'
GROUP BY pedido_id HAVING COUNT(*)>1
PASS: Cero duplicados, ≤6.4s demora
Referencias
docs/experiments.md: Hipótesis y criterios detalladosdocs/quality-attributes.md: ASR-1 a ASR-4notas/plan-implementacion-experimentos.md: Supuestos S-1 a S-12analisis/e01.sql,analisis/e02.sql: Veredictos exactos
Última actualización: 2026-10-12
Compilado para: Java 21, Spring Boot 3, PostgreSQL 17, RabbitMQ 4.1