Al terminar esta lección
- Distinguir rollback de código, reversión de datos y recuperación, y saber cuál aplica en cada falla.
- Escribir una migración expand/backfill/contract que sobreviva a datos históricos feos y a una versión vieja que sigue escribiendo.
- Ensayar fallos con un pipeline, un entorno de demo y un smoke test que pueden detectarlos sin avisar.
- Decidir go/no-go con criterios escritos, responsables nombrados y una condición para postergar.
11.6 Integrar es cambiar un sistema vivo
Una feature puede ser correcta en aislamiento y fallar al desplegarse por migraciones, datos históricos, configuración, permisos o dependencias externas. Por eso el plan de integración forma parte de la solución. La pregunta no es solo “cómo llega el código a producción”, sino qué estados coexistirán durante el cambio, qué datos van a encontrar las nuevas reglas y cómo volvemos atrás si algo sale mal.
El caso de esta lección es el release 1 de Nexo B2B: pedido interno con crédito, autorización de excepción, reserva por depósito, idempotencia por requestId y entrega a preparación mediante outbox. La facturación fiscal (CAE) queda fuera de este release. El pedido queda marcado como “pendiente de facturación” y la integración fiscal se ensaya contra un simulador del proveedor, sin emitir comprobantes reales. Ese recorte no es un detalle de alcance: define qué se puede demostrar y qué no.
Nexo B2B · El perfilado que nadie quería correr
Dos semanas antes del release, el equipo corre por primera vez la migración contra una copia de la base histórica. Son 200.000 pedidos. El dry-run de la primera versión de la migración falla en menos de un minuto. El mensaje dice que una columna no puede ser nula, y el equipo descubre que 1.412 pedidos antiguos no tienen depósito asignado. Además, 37 identificadores externos aparecen repetidos. Ninguno de esos casos existía en los fixtures del repositorio, que siempre tienen tres filas y un depósito.
La primera versión de la migración era esta:
-- Versión rechazada en el dry-run. No aplicar.
ALTER TABLE idempotency_keys ADD COLUMN IF NOT EXISTS request_hash text;
ALTER TABLE idempotency_keys ALTER COLUMN request_hash SET NOT NULL;
ALTER TABLE idempotency_keys DROP COLUMN payload_md5;
CREATE UNIQUE INDEX idempotency_keys_request_hash_uq ON idempotency_keys (request_hash);
ALTER TABLE order_lines RENAME COLUMN unit_price_cents TO price_cents;
ALTER TABLE orders ALTER COLUMN deposit_id SET NOT NULL;
Antes de leer la explicación, buscá los problemas. Hacé clic en las líneas que no pueden ir en el mismo release, aunque cada una sea correcta en un repositorio limpio.
¿Qué líneas de esta migración rompen producción?
Hacé clic en las líneas que fallan con datos históricos o con instancias de la versión anterior todavía en ejecución. La línea 1 es correcta.
El equipo descartó la migración y aplicó el patrón de la lección anterior. La primera fase solo agrega. Está en el archivo que ya viste en el fundamento, y el índice se crea en un paso operativo aparte, con conteo de locks antes y después.
Nexo B2B · Expand, backfill y cuarentena
El plan nuevo tiene tres piezas. La migración 0005 agrega las columnas y no toca datos. Un job de backfill completa
request_hashen lotes de 500 filas, con el mismo cálculo que el fingerprint de ADR-006. Y la migración 0007 se aplica solo cuando un gate verifica que no quedan filas migrables y que la versión 1 dejó de recibir tráfico. Los 1.412 pedidos sin depósito no se migran: pasan a una tabla de cuarentena con su motivo, porque asignarles un depósito por adivinación inventaría un dato que el negocio no respaldó.
El backfill se escribió para correr en lotes, no como un UPDATE sobre toda la tabla:
-- Repetir hasta que la consulta de progreso devuelva 0.
-- Cada corrida toca como máximo 500 filas y no retiene locks sobre la tabla completa.
-- El cálculo debe ser idéntico a src/domain/request-fingerprint.ts: el test de paridad lo verifica en CI.
WITH lote AS (
SELECT k.order_id
FROM idempotency_keys k
JOIN orders o ON o.id = k.order_id
WHERE k.request_hash IS NULL
AND o.deposit_id IS NOT NULL -- sin depósito: no se migra, va a cuarentena
ORDER BY k.order_id
LIMIT 500
),
canon AS (
SELECT
o.id AS order_id,
o.client_id || '|' || o.deposit_id || '|' ||
string_agg(s.sku || ':' || s.qty::text, ',' ORDER BY s.sku) AS repr
FROM lote
JOIN orders o ON o.id = lote.order_id
JOIN LATERAL (
SELECT upper(trim(l.sku)) AS sku, sum(l.quantity) AS qty
FROM order_lines l
WHERE l.order_id = o.id
GROUP BY upper(trim(l.sku))
) s ON true
GROUP BY o.id, o.client_id, o.deposit_id
)
UPDATE idempotency_keys k
SET request_hash = encode(sha256(convert_to(c.repr, 'UTF8')), 'hex')
FROM canon c
WHERE k.order_id = c.order_id
AND k.request_hash IS NULL;
-- Progreso: el gate exige 0 filas migrables pendientes.
SELECT count(*) AS pendientes_migrables
FROM idempotency_keys k
JOIN orders o ON o.id = k.order_id
WHERE k.request_hash IS NULL AND o.deposit_id IS NOT NULL;
Fijate en tres decisiones del archivo. El lote se limita con LIMIT y se repite, así que una interrupción no deja la tabla a medio escribir: la siguiente corrida retoma desde las filas con hash en NULL. El filtro por depósito es explícito, y las filas excluidas son las que van a cuarentena, no las que se ignoran en silencio. Y el cálculo está atado al fingerprint del código: si uno cambia y el otro no, el hash histórico nunca coincide con el que calcula la aplicación nueva. El test de paridad es lo que evita esa falla silenciosa.
La cuarentena no es un descarte. Se hace con una consulta que copia las filas a idempotency_keys_legacy con un motivo, las quita de la tabla activa y deja un registro para negocio:
INSERT INTO idempotency_keys_legacy (request_id, order_id, created_at, motivo)
SELECT k.request_id, k.order_id, k.created_at, 'pedido_sin_deposito'
FROM idempotency_keys k
JOIN orders o ON o.id = k.order_id
WHERE k.request_hash IS NULL
AND o.deposit_id IS NULL;
DELETE FROM idempotency_keys k
USING idempotency_keys_legacy l
WHERE l.order_id = k.order_id
AND l.motivo = 'pedido_sin_deposito';
Ese paso debe ser idempotente y auditado; por eso el INSERT y el DELETE van en una sola transacción en el ensayo, y el conteo de filas copiadas tiene que coincidir con el de filas borradas antes de dar por buena la operación.
Y la fase contract, que es la única irreversible sin backup:
-- Fase CONTRACT. Solo se aplica con el gate cumplido.
-- Precondiciones que el pipeline verifica antes de ejecutar:
-- a) pendientes_migrables = 0
-- b) las filas sin depósito están en idempotency_keys_legacy
-- c) 0 requests de la versión 1 en las últimas 24 horas (telemetría)
ALTER TABLE idempotency_keys
ADD CONSTRAINT idempotency_keys_request_hash_not_null
CHECK (request_hash IS NOT NULL) NOT VALID;
ALTER TABLE idempotency_keys
VALIDATE CONSTRAINT idempotency_keys_request_hash_not_null;
ALTER TABLE idempotency_keys
ALTER COLUMN request_hash SET NOT NULL;
ALTER TABLE idempotency_keys
DROP CONSTRAINT idempotency_keys_request_hash_not_null;
ALTER TABLE idempotency_keys
DROP COLUMN payload_md5;
El orden de la migración evita dos problemas. Con NOT VALID, la validación no escanea la tabla con un lock fuerte, y VALIDATE lo hace sin bloquear lecturas ni escrituras. Después, SET NOT NULL aprovecha el CHECK ya validado para no reescanear. Y la eliminación de payload_md5 queda al final, con su precondición escrita, para que volver atrás antes de ese punto sea solo un redeploy.
Nexo B2B · Datos feos antes del game day
El game day se hace sobre una copia anonimizada de la base de staging. La consulta de perfilado encuentra lo que los fixtures no tenían: 327 clientes sin categoría de crédito, 41 acuerdos de precio sin fecha de vigencia y nueve SKU que el ERP renumeró. El equipo no arregla esos datos a mano para que la demo siga. Cada grupo recibe una política escrita.
-- Conteos previos. Cada número se guarda en el runbook junto con la fecha de ejecución.
SELECT count(*) AS clientes_sin_categoria
FROM clients
WHERE credit_category IS NULL; -- esperado: 327
SELECT count(*) AS acuerdos_sin_vigencia
FROM price_agreements
WHERE valid_to IS NULL; -- esperado: 41
SELECT count(*) AS skus_renumerados
FROM sku_renumbering; -- esperado: 9, con origen, destino y fecha de cambio
Las decisiones fueron estas. Los clientes sin categoría no reciben “ESTÁNDAR” por defecto. Se migran con estado REQUIERE_REVISION_CREDITO, y el sistema no permite confirmarles pedidos a crédito hasta que administración los clasifique. Esa decisión sacrifica automatización para preservar seguridad comercial. El conteo posterior tiene que llegar a cero antes de habilitar el flujo completo para esos clientes.
Los acuerdos sin fecha son más delicados. Comercial confirma que algunos siguen vigentes y otros son reliquias. La migración no les pone fecha. Se crea una campaña de saneamiento fuera del código: exportación, propietario y fecha de revisión por acuerdo. Mientras tanto, el sistema exige una vigencia validada para aplicar un precio especial. Sin vigencia, el pedido queda pendiente de revisión comercial. La migración expone una deuda del negocio en lugar de congelarla como dato eterno.
Los nueve SKU renumerados se resuelven con la tabla sku_renumbering, que tiene origen, destino y fecha de cambio. Nadie adivina por descripción que un código nuevo es el viejo. Un SKU sin correspondencia trazable se rechaza con error explícito.
Antes de que la copia anonimizada llegue al entorno de demo, hay una pregunta más básica: qué datos pueden entrar y cuáles no. Clasificalos.
¿Qué datos pueden entrar al entorno de demo?
Elegí una categoría para cada dato. Fijate en qué entorno puede usarse, no solo si parece sensible.
Cliente DEMO-CLI-07, “Ferretería Demo 07”, con límite de 500.000,00 y saldo de 480.000,00.
Copia anonimizada de pedidos reales, con IDs externos reemplazados por hashes.
CUIT real de un cliente, con las dos primeras cifras reemplazadas por ceros.
Lista de precios exportada del ERP de la empresa, sin modificar.
Foto de un pedido escrito a mano por un vendedor.
Dataset de 2.000 pedidos generados con una semilla fija, con 15 pedidos sin depósito y 3 IDs externos repetidos.
Nexo B2B · Ensayo de fallos: el ERP lento y la respuesta perdida
A las 11:40 del día de ensayo, el ERP simulado empieza a demorar 28 segundos por respuesta. El flujo de creación de pedido consulta maestros de precio y stock al ERP, y el proxy corta la conexión a los 30. El vendedor ve un error y reintenta desde el móvil. La primera request había llegado a confirmar justo antes del corte, así que el pedido existe. El segundo request llega con el mismo
requestIdy el mismo contenido. La clave idempotente devuelve el pedido existente. Nadie carga dos veces.
El ensayo no termina ahí. Para la fase de integración fiscal, el simulador del proveedor procesa la intención y pierde la respuesta, un modo de falla que el propio simulador puede producir a pedido. El worker no reintenta a ciegas: consulta por la clave de intención y vuelve a enviar solo si el proveedor confirma que no la recibió. El simulador devuelve el mismo resultado y no duplica nada. Este comportamiento se verifica en staging con el flag fiscal.emission.enabled encendido, porque ese entorno solo habla con el simulador. En producción el flag no puede activarse, y el guard del proceso lo impide.
Para este ensayo, el entorno de demo se levanta con Docker Compose. Todo lo que contiene es sintético:
# Entorno de demo y ensayo. Datos sintéticos. No apunta a ningún ERP ni proveedor real.
name: nexo-demo
x-app-env: &app-env
NEXO_ENV: demo
DATABASE_URL: postgres://nexo:${DEMO_DB_PASSWORD:?definí DEMO_DB_PASSWORD}@db:5432/nexo
ERP_BASE_URL: http://erp-sim:8080
FISCAL_PROVIDER: simulator
FISCAL_SIMULATOR_URL: http://fiscal-sim:8081
FF_FISCAL_EMISSION_ENABLED: "true" # solo demo y staging: emite contra el simulador, nunca contra el proveedor real
LOG_LEVEL: info
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: nexo
POSTGRES_USER: nexo
POSTGRES_PASSWORD: ${DEMO_DB_PASSWORD:?definí DEMO_DB_PASSWORD}
volumes:
- demo-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nexo -d nexo"]
interval: 5s
timeout: 3s
retries: 10
erp-sim:
build: ./erp-sim
environment:
ERP_SIM_LATENCY_MS: "0" # el escenario de ERP lento lo sube en caliente
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
fiscal-sim:
build: ./fiscal-sim
environment:
FISCAL_SIM_MODE: ok # ok | timeout-despues-de-procesar | error-permanente
FISCAL_SIM_BANNER: "SIMULADO: no es un comprobante válido"
migrate:
image: nexo/app:${NEXO_IMAGE_DIGEST:?definí NEXO_IMAGE_DIGEST}
command: ["node", "dist/scripts/migrate.js", "up"]
environment: *app-env
depends_on:
db:
condition: service_healthy
restart: "no"
seed:
image: nexo/app:${NEXO_IMAGE_DIGEST}
command: ["node", "dist/scripts/seed-demo.js", "--seed", "2026-10-12"]
environment: *app-env
depends_on:
migrate:
condition: service_completed_successfully
restart: "no"
app:
image: nexo/app:${NEXO_IMAGE_DIGEST}
command: ["node", "dist/server.js"]
environment: *app-env
depends_on:
seed:
condition: service_completed_successfully
ports:
- "127.0.0.1:3000:3000" # solo loopback: la demo no se expone a la red
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health/live"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
worker:
image: nexo/app:${NEXO_IMAGE_DIGEST}
command: ["node", "dist/worker.js"]
environment: *app-env
depends_on:
seed:
condition: service_completed_successfully
restart: unless-stopped # el worker se mide por el lag del outbox, no por HTTP
volumes:
demo-pgdata:
Hay tres decisiones que conviene explicar. migrate y seed son servicios de un solo uso con service_completed_successfully, así que la app no arranca sobre un schema a medio migrar ni sobre datos sin sembrar. La imagen se referencia por digest, nunca por latest, para que el ensayo y lo que se ensaya sean el mismo artefacto. Y el puerto de la app queda ligado a 127.0.0.1, porque una demo con datos sintéticos igual no tiene por qué estar accesible desde la red de la oficina.
Esa configuración es el resultado de corregir una versión anterior. Compará la versión defectuosa con la que acabás de leer:
¿Qué tiene mal este compose de demo?
Hacé clic en las líneas que harían fallar el ensayo, exponer datos o cambiar el comportamiento de un entorno que debe ser sintético.
Nexo B2B · Ensayo de despliegue por digest, con gates
El release se despliega con un pipeline que no confía en la memoria de quien lo ejecuta. El diagrama muestra el orden de las interacciones entre el responsable, el pipeline, la base y las versiones que conviven durante la transición.
El pipeline completo está en un único archivo. Cada job tiene un gate explícito, y el de producción tiene un environment con revisores requeridos, así que ninguna persona puede saltarse la aprobación con un push.
name: release
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: release
cancel-in-progress: false # un release no se cancela a mitad de una migración
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm run test:unit
integration:
needs: verify
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: nexo_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-retries 10
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/nexo_test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Migraciones desde cero y compatibilidad con la versión anterior
run: |
npm run db:migrate -- up
npm run test:compat-n1
- name: Pruebas de integración críticas
run: npm run test:integration -- stock-reservation idempotency-conflict
build:
needs: integration
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@v4
- name: Construir y publicar la imagen
id: push
run: ./scripts/build-image.sh
staging:
needs: build
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Migrar staging en fase expand
run: ./scripts/migrate.sh --phase expand
env:
DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}
- name: Desplegar por digest
run: ./scripts/deploy.sh staging "${{ needs.build.outputs.digest }}"
- name: Smoke de demo con datos sintéticos
run: ./scripts/smoke-demo.sh "${{ vars.STAGING_URL }}"
production:
needs: staging
runs-on: ubuntu-latest
environment: production # revisores requeridos configurados en GitHub
steps:
- uses: actions/checkout@v4
- name: Verificar que el cambio contiene solo expand
run: ./scripts/assert-expand-only.sh
- name: Migrar producción en fase expand
run: ./scripts/migrate.sh --phase expand
env:
DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
- name: Desplegar por digest
run: ./scripts/deploy.sh production "${{ needs.build.outputs.digest }}"
- name: Smoke de solo lectura
run: ./scripts/smoke-readonly.sh "${{ vars.PROD_URL }}"
Dos gates merecen atención. assert-expand-only.sh falla si el cambio que llega a producción contiene una migración de contract: esa fase tiene su propio gate, con aprobación separada, y nunca viaja con el código. Y el smoke de producción es de solo lectura. No crea pedidos. Un smoke que escribe en producción contamina datos reales, así que el de escritura vive solo en staging y en demo.
El smoke de demo es el que de verdad prueba comportamiento. Corre contra datos sintéticos y verifica las reglas que la demo promete:
#!/usr/bin/env bash
# file: scripts/smoke-demo.sh
# Uso: scripts/smoke-demo.sh https://staging.nexo.internal
# Crea pedidos sintéticos (DEMO-*). Nunca se ejecuta contra producción: para eso existe smoke-readonly.sh.
set -euo pipefail
BASE_URL="${1:?Falta la URL base}"
RUN_ID="smoke-$(date +%s)-$RANDOM"
BODY_FILE="$(mktemp)"
FAILURES=0
trap 'rm -f "$BODY_FILE"' EXIT
request() { # request METODO RUTA [JSON]: imprime el status y deja el cuerpo en BODY_FILE
local method="$1" path="$2" data="${3:-}"
local args=(-sS -o "$BODY_FILE" -w '%{http_code}' --max-time 10 -X "$method" "$BASE_URL$path"
-H 'content-type: application/json')
if [[ -n "$data" ]]; then args+=(--data "$data"); fi
curl "${args[@]}"
}
field() { # field NOMBRE: extrae un campo de texto simple del cuerpo JSON
sed -n "s/.*\"$1\":\"\([^\"]*\)\".*/\1/p" "$BODY_FILE" | head -n1
}
check() { # check NOMBRE COMANDO...
local name="$1"; shift
if "$@"; then
echo "OK $name"
else
echo "FALLA $name"
FAILURES=$((FAILURES + 1))
fi
}
order_body() { # order_body REQUEST_ID CLIENTE SKU CANTIDAD
printf '{"requestId":"%s","sellerId":"DEMO-VEN-01","clientId":"%s","items":[{"sku":"%s","quantity":%s}]}' "$1" "$2" "$3" "$4"
}
# Si el endpoint no responde, set -e corta el script: eso también es una falla.
status=$(request GET /health/ready)
check "readiness responde 200" test "$status" = "200"
status=$(request POST /orders "$(order_body "$RUN_ID-a" DEMO-CLI-01 DEMO-SKU-01 1)")
check "pedido normal se confirma (201)" test "$status" = "201"
check "resultado confirmed" grep -q '"kind":"confirmed"' "$BODY_FILE"
ORDER_ID="$(field orderId)"
status=$(request POST /orders "$(order_body "$RUN_ID-a" DEMO-CLI-01 DEMO-SKU-01 1)")
check "reintento idéntico no duplica el pedido" test "$(field orderId)" = "$ORDER_ID"
status=$(request POST /orders "$(order_body "$RUN_ID-a" DEMO-CLI-01 DEMO-SKU-01 2)")
check "mismo requestId con otro contenido da 409 (RN-06)" test "$status" = "409"
status=$(request POST /orders "$(order_body "$RUN_ID-b" DEMO-CLI-01 DEMO-SKU-SIN-STOCK 1)")
check "sin stock responde 422 sin efectos" test "$status" = "422"
status=$(request POST /orders "$(order_body "$RUN_ID-c" DEMO-CLI-09 DEMO-SKU-01 1)")
check "vendedor fuera de cartera recibe 403 (NFR-03)" test "$status" = "403"
echo
if (( FAILURES > 0 )); then
echo "Smoke con $FAILURES falla(s)."
exit 1
fi
echo "Smoke OK."
Cada check corresponde a una promesa de la demo. Si el reintento idéntico creara un segundo pedido, la falla aparece antes de que el vendedor lo vea. Eso es lo que el smoke vale: no prueba que “la app abre”, sino que las reglas que vamos a mostrar se cumplen en el entorno que vamos a mostrar.
Nexo B2B · Practicar el rollback con honestidad
El ensayo del rollback empieza con una pregunta incómoda: ¿qué pasa si la versión 1 hay que retirarla hoy? El equipo distingue tres cosas que en el lenguaje cotidiano se mezclan. El rollback de código vuelve el contenedor a la imagen anterior. La reversión de datos deshace escrituras, y en este caso no existe porque el expand no borró nada. La recuperación es todo lo que hace falta para volver a operar, incluido restaurar un backup si la base quedó dañada.
Los números del ensayo cambiaron el plan. Volver al digest anterior, con el schema ya expandido, tomó 9 minutos en staging, incluido el readiness. Restaurar un backup completo en un entorno aislado tomó 3 horas y 10 minutos. Para un incidente pequeño, como una reserva huérfana en tres pedidos, esa restauración es desproporcionada. El equipo agregó procedimientos de reparación focal, con consultas que corrigen solo las filas afectadas y criterios explícitos sobre cuándo usar cada mecanismo.
El plan de rollback quedó escrito así:
Disparadores: cualquiera de estos alcanza para pausar el release.
- Errores 5xx mayores al 2 por ciento durante 5 minutos en el endpoint de pedidos.
- Una reserva negativa en la consulta de invariantes (INV-01). Rollback inmediato.
- Pedidos duplicados para un mismo
requestId(RN-06). Rollback inmediato. - Lag del outbox mayor a 15 minutos y sostenido.
- Un p95 del registro de pedido mayor a 800 ms durante 10 minutos (NFR-01).
Quién decide: el responsable de release, con autoridad para pausar sin esperar al resto del equipo. Quién ejecuta: operaciones. Quién informa: comercial a los vendedores, y depósito al personal de preparación.
Pasos, en orden:
- Apagar los flags nuevos:
FF_ORDERS_REQUEST_HASH_IDEMPOTENCY=falseyFF_ORDERS_PRICE_SOURCE_SNAPSHOT=false. No requiere deploy. Objetivo: 2 minutos. - Si el problema viene del handoff, pausar el worker. El outbox conserva los eventos; nada se pierde mientras el worker está detenido.
- Redesplegar la imagen anterior por digest, nunca por tag. Objetivo: 10 minutos.
- Verificar readiness y el smoke de solo lectura contra la versión anterior. El schema 5 sigue siendo compatible con la versión 4: el expand no le quitó nada.
- Verificar invariantes: ninguna reserva negativa y ningún pedido sin clave idempotente.
- Reanudar el worker y mirar el lag del outbox hasta que llegue a cero.
- Comunicar el estado a comercial y depósito, con la hora, el motivo y la siguiente revisión.
Lo que este plan no hace también está escrito. No revierte migraciones. La fase contract no se ejecuta durante la ventana del incidente. Y la restauración completa de backup solo se usa si hay corrupción que la reparación focal no puede corregir, porque cuesta casi cuatro horas medidas.
Plan de rollback del release 1: resumen de decisiones
Objetivo de decisión: 5 minutos desde el disparador.
Objetivo de rollback de aplicación: 10 minutos (medido en staging: 9).
Restauración completa de backup: 3 h 10 min (medido en entorno aislado). Último recurso.
Contract: fuera de la ventana de rollback. Requiere go separado.
Reparación focal: consultas versionadas en db/ops/repair/, una por tipo de inconsistencia.
Nexo B2B · La demo: cinco historias y ningún menú
La demo del release 1 dura veinte minutos y no recorre menús. Cuenta cinco historias. Cada una tiene un estado inicial preparado, una acción del vendedor o del depósito, una señal que el público puede ver y un criterio de éxito escrito antes del ensayo. Ninguna supone que el sistema funcionó: todas están pensadas para que algo pueda salir mal y el sistema lo muestre.
- Historia 1: pedido normal. Estado inicial: DEMO-CLI-01 con saldo bajo el límite. Acción: el vendedor carga dos líneas desde el pedido de WhatsApp reescrito. Señal: pedido confirmado, precio con origen registrado y reserva creada. Criterio: el total coincide con la lista vigente y no hay reserva negativa.
- Historia 2: autorización. Estado inicial: DEMO-CLI-04 con saldo más un pedido que supera el límite por un centavo (AC-03). Acción: el vendedor intenta confirmar. Señal: el pedido queda en PENDIENTE_AUTORIZACION, con la regla RN-01 y sin reserva firme. El responsable de crédito autoriza con motivo obligatorio. Criterio: la autorización queda auditada con actor y motivo, y el límite permanente del cliente no cambia.
- Historia 3: última unidad. Estado inicial: COR-118 con una sola unidad disponible en CEN. Acción: dos vendedores confirman pedidos distintos al mismo tiempo. Señal: uno obtiene la reserva y el otro recibe stock insuficiente. Criterio: la disponibilidad final es cero y nunca negativa (AC-06).
- Historia 4: integración lenta. Estado inicial: el ERP simulado con 28 segundos de latencia. Acción: el vendedor confirma un pedido y reintenta desde el móvil. Señal: primer intento con respuesta 503
temporarily_unavailable, sin pedido parcial, y reintento que devuelve un único pedido. Criterio: el mismorequestIdno genera efectos dobles (AC-07). - Historia 5: quiebre físico en picking. Estado inicial: AX-442 con 8 unidades en CBA, 6 reservadas por pedidos previos, y un pedido por 3. Acción: el pedido se rechaza por falta de disponibilidad, y en otro caso el picker encuentra 6 físicas donde el sistema decía 8. Señal: el quiebre queda registrado como excepción auditada y la reserva se reajusta. Criterio: la demo muestra lo que el sistema no resuelve: el stock físico sigue siendo responsabilidad del depósito y la excepción queda visible.
La quinta historia es la que más incomoda y la que más sirve. Dice qué protege el sistema y qué depende todavía de la calidad del inventario físico. Ocultarla convierte la demo en propaganda.
El procedimiento de la demo también está escrito, con tiempos, fallback y reset. Un extracto del runbook:
Antes de la demo (T menos 60 minutos)
- Reset determinista: scripts/demo-reset.sh (recrea la base con la semilla 2026-10-12).
- Smoke: scripts/smoke-demo.sh contra la URL de demo. Si falla, no se hace la demo.
- Flags: verificar que coinciden con el registro de la lección 11.1.
- ERP simulado con latencia en 0. Se sube solo durante la historia 4.
Durante la demo
- Historia 4: ejecutar scripts/erp-sim-latency.sh 28000 justo antes de la acción.
- Si una historia falla: mostrar el evento en el log y la métrica correspondiente, decir qué esperábamos y pasar a la siguiente. No reiniciar en vivo.
- Ninguna historia se corrige con una base modificada a mano.
Después de la demo
- Anotar observaciones y preguntas textuales de los asistentes.
- Reset del dataset antes de la siguiente sesión.
Nexo B2B · Go/no-go: la condición que hace falta escribir
Viernes, 17:30. La reunión de decisión reúne al responsable de release, comercial, el responsable de depósito y operaciones. Los números del ensayo están en la mesa: el smoke pasó, el rollback se midió, la cuarentena tiene 1.412 pedidos y hay 41 acuerdos sin vigencia. Comercial quiere salir el martes.
# Un go requiere todas las condiciones escritas. Una condición sin responsable no es una condición.
go_si:
migracion_0005_ensayada_sobre_copia: true
migracion_0007_bloqueada_hasta_gate: true
backup_restaurado_en_aislado: "3 h 10 min medidas"
smoke_demo_verde_en_staging: true
rollback_aplicacion_medido: "9 min"
game_day_registrado: "4 fallos detectados por señal, ninguno sin runbook"
unknowns_aceptados:
- "TTL de reserva (RN-07): BLOCKED. El soft hold queda apagado."
- "41 acuerdos sin vigencia: quedan en revisión, responsable comercial, fecha de revisión por definir"
- "327 clientes en REQUIERE_REVISION_CREDITO: no confirman a crédito hasta clasificación"
responsables:
decision: "responsable de release"
rollback: "operaciones"
comunicacion: "comercial"
criterio_para_abortar: "ver disparadores del plan de rollback"
ScenarioSim:
Viernes 17:30: ¿salimos el martes?
Estás en la reunión de go/no-go con comercial y depósito. Hay 41 acuerdos sin vigencia en revisión y 327 clientes que no pueden confirmar a crédito. Comercial quiere salir el martes. Tenés que decidir qué condición escribir.
Responsable comercial
Son 41 acuerdos sobre miles. Salimos el martes y los revisamos después.
¿Qué respondés?
El release también tiene un ciclo de vida. Un go/no-go solo tiene sentido si las transiciones están permitidas por reglas: no se puede desplegar un release que no pasó el ensayo, ni contraer una base mientras haya versiones viejas. El diagrama fija esas transiciones.
Dos transiciones importan más que las demás. Verificado a Contraido es la única que vuelve irreversible el release sin restaurar un backup, y por eso exige un gate propio. Desplegado a Revertido no borra el historial: el release vuelve a Ensayado solo cuando la causa está corregida y el ensayo se repitió.
11.7 Observabilidad diseñada desde preguntas
Loguear todo produce ruido y riesgo de exponer datos. Observabilidad útil empieza con preguntas operativas: ¿por qué este pedido quedó pendiente?, ¿cuánto tardó la autorización?, ¿la integración fiscal respondió?, ¿un request se procesó dos veces?, ¿qué actor autorizó la excepción? Para responderlas necesitamos identificadores correlacionables, eventos de negocio y métricas específicas.
Un correlation ID ayuda a seguir una operación técnica, pero no reemplaza los IDs del dominio. Un evento de auditoría registra pedido, actor, acción, regla aplicada y resultado. La instrumentación debe permitir reconstruir incidentes sin registrar secretos ni datos personales innecesarios.
En el release 1, las métricas que responden a las preguntas del ensayo son estas:
idempotent_replays_total: cuántos requests devolvieron un pedido existente. Un salto sin reintentos del cliente indica un problema en la red o en el proxy.orders_rejected_total{reason}: rechazos por crédito, stock, SKU inválido o cartera. Sirve para ver si una regla nueva rechaza más de lo esperado.reservation_conflicts_total: carreras por la última unidad detectadas por la base. Debería aparecer en la historia 3 y en ningún otro momento.outbox_oldest_pending_age_seconds: la antigüedad del evento más viejo sin procesar. Es la señal que hace visible un worker detenido.http_request_duration_seconds: histograma de latencia para verificar NFR-01 sobre el registro de pedido, con 30 usuarios concurrentes.
Cada métrica tiene una alerta con una pregunta y un runbook. Sin pregunta, la alerta es ruido. Sin runbook, es una alarma que nadie sabe apagar.
11.8 La demo como prueba narrativa
Una buena demo no recorre el menú. Cuenta una promesa. Empieza con el problema original, ejecuta un caso normal, muestra una excepción importante y enseña evidencia de que el sistema conserva garantías cuando algo falla. Si la demo solo muestra pantallas bonitas, no demuestra que el proyecto resolvió el riesgo que justificó la inversión.
Por eso las cinco historias del release 1 están elegidas por su riesgo, no por su cobertura de pantallas. Cada una prueba una garantía: el límite de crédito (RN-01), la trazabilidad de la autorización (NFR-02), la ausencia de sobreventa (RN-05), la idempotencia del reintento (RN-06) y el límite entre lo que el sistema sabe y lo que pasa en el depósito.
11.9 Datos históricos: el sistema nuevo hereda el pasado
Los datos existentes rara vez respetan las invariantes del modelo nuevo. Puede haber clientes sin categoría, pedidos sin depósito, identificadores externos repetidos, precios sin vigencia o estados que ya no existen. Una migración profesional primero perfila los datos, define estrategias y registra excepciones. Aplicar constraints ideales sin mirar el dataset puede detener el despliegue, como le pasó a la versión 0 de la migración.
La decisión sobre cada anomalía puede ser limpiar, mapear, aceptar un estado legacy, aislar el registro o pedir una regla al negocio. Lo importante es que la migración no invente significado. Si un campo antiguo estado=9 no está documentado, no lo convertimos en CANCELADO porque parece plausible. Si un SKU renumerado no tiene correspondencia trazable, no lo asignamos por parecido de descripción.
En el ensayo, cada anomalía tuvo una decisión registrada en el runbook de migración:
- 327 clientes sin categoría: estado
REQUIERE_REVISION_CREDITO, sin confirmación a crédito hasta clasificar. Conteo previo y posterior. - 41 acuerdos sin vigencia: quedan en revisión y no se aplican. Campaña de saneamiento con propietario y fecha.
- 9 SKU renumerados: tabla de correspondencia con origen, destino y fecha. Sin correspondencia, error explícito.
- 1.412 pedidos sin depósito: cuarentena con motivo. No se migran hasta que negocio defina la regla.
- 37 identificadores externos repetidos: se reportan para resolver con el sistema de origen. La migración no elige cuál de los duplicados es el correcto.
Microtaller · Perfilado de datos
Inventá un dataset legacy con cinco anomalías que violen tu modelo nuevo. Escribí una consulta de conteo para cada una, una política de tratamiento que no invente semántica y un registro de cuarentena con motivo. Después comprobá que el conteo previo y el posterior coinciden con lo que decidiste.
11.10 Rollback de código no siempre es rollback de datos
Volver al contenedor anterior puede ser fácil. Deshacer una migración destructiva o efectos externos puede no serlo. Por eso un plan de rollback debe considerar la compatibilidad del schema y las operaciones ya ejecutadas. El expand facilita que versiones antigua y nueva convivan durante la transición, pero solo si nadie hizo un contract antes de tiempo.
Un rollback de código sin rollback de datos puede ser una falsa sensación de seguridad. Si la versión nueva escribió pedidos con un formato que la anterior no entiende, volver atrás deja a la versión vieja con datos que no puede leer. Por eso el ensayo verifica que la versión 4 atiende pedidos sobre el schema 5, y no que el deploy de rollback termine con status 200.
11.11 Checklist de go/no-go
Antes de desplegar, el equipo debe poder responder: ¿migración ensayada sobre copia?, ¿backup restaurable y restaurado?, ¿flags o compatibilidad verificados?, ¿smoke tests verdes?, ¿métricas y logs con preguntas asociadas?, ¿responsable de decisión si algo falla?, ¿criterio escrito para abortar?, ¿cómo se comunica al cliente? Si alguna respuesta depende de “creo que”, el release todavía no tiene go.
11.12 La operación futura también es parte del diseño
Un sistema no está terminado cuando compila y pasa tests locales. Tiene que entrar en una operación que ya existe, con datos históricos, horarios, usuarios, integraciones, fallos y procedimientos. La preparación del release es otra oportunidad para descubrir supuestos. Si una migración requiere que todos los clientes tengan categoría y un 12 por ciento no la tiene, el problema existe aunque el esquema nuevo sea perfecto.
El runbook traduce los fallos esperados en acciones: qué mirar, cómo diagnosticar, cuándo reintentar, cuándo escalar, cómo pausar un worker y cómo verificar la integridad después de recuperar. Si la respuesta a un incidente crítico es “que lo vea quien programó”, la solución todavía depende de memoria individual. Un equipo chico necesita menos burocracia, no menos operabilidad.
La IA puede analizar logs, generar consultas de verificación y comparar conteos antes y después de una migración. No debe decidir automáticamente que un registro histórico inválido puede descartarse. La autoridad sobre la corrección sigue en negocio y en las reglas acordadas. Durante un game day, el agente es una herramienta de diagnóstico, no el responsable del go/no-go.
Comprobá tu criterio
El rollback de código funciona en staging. ¿Qué falta para dar por ensayada la recuperación del release?
Errores frecuentes
- Síntoma: el smoke de staging pasa, pero el primer pedido real de producción falla con violación de NOT NULL. Causa: el ensayo usó fixtures con todas las filas completas; los datos históricos nunca se perfilaron. Corrección: perfilar la copia de producción (nulos, duplicados, referencias huérfanas) antes de escribir la migración, y guardar los conteos en el runbook.
- Síntoma: después de un rollback de código, la versión anterior devuelve errores al leer pedidos. Causa: la versión nueva escribió un formato que la anterior no entiende, o alguien ejecutó el contract antes de tiempo. Corrección: verificar la compatibilidad N-1 sobre el schema expandido y registrar el contract como paso con gate propio.
- Síntoma: un reintento del vendedor crea un segundo pedido después de un corte de red. Causa: el
requestIdno se persiste dentro de la misma transacción que el pedido, o la clave se calcula con el payload completo y cambia con cada reintento. Corrección: la clave y el pedido se confirman juntos, y el fingerprint ignora tracing y metadata. - Síntoma: la demo sale bien y el día siguiente el worker lleva horas detenido sin que nadie lo note. Causa: el ensayo nunca detuvo el worker, y el readiness no mide el lag del outbox. Corrección: incluir en el game day la falla del worker, y alertar sobre la antigüedad del evento más viejo.
- Síntoma: el go se aprueba con “todo parece bien” y después nadie sabe quién decidió qué. Causa: no hay condiciones escritas, responsables nombrados ni criterio de aborto. Corrección: el go/no-go se firma contra el archivo de condiciones, y cada unknown aceptado tiene dueño y fecha.
Para llevarte
- El rollback de código, la reversión de datos y la recuperación son tres mecanismos distintos. Cada uno tiene su tiempo medido y su criterio de uso.
- Una migración de release se diseña contra la copia de producción, no contra fixtures. Los datos históricos siempre tienen algo que el modelo nuevo no admite.
- El expand se despliega, el backfill se repite en lotes, el contract se aplica con gate. Ninguna fase depende de la anterior sin verificación.
- Un compose y un pipeline de demo son artefactos de release: imágenes por digest, dependencias con condición de salud, gates con responsables y un smoke que prueba las reglas que vas a mostrar.
- La demo tiene cinco historias elegidas por riesgo. La quinta muestra lo que el sistema no resuelve, y esa parte también se presenta.
- El go/no-go se escribe como condiciones con dueño. Postergar también es una decisión válida, si la condición para decidir está medida.
IA EN ESTA FASE
Qué puede hacer el agente: revisar un plan de migración contra la lista de operaciones que bloquean tablas, proponer consultas de perfilado para las anomalías que el equipo no nombró, comparar la salida de un smoke test contra el plan de demo y redactar el borrador del runbook a partir de las decisiones ya tomadas.
Qué no debe decidir: qué hacer con un pedido sin depósito o con un acuerdo sin vigencia (eso lo decide negocio), si un unknown se acepta para salir, cuándo se ejecuta el contract, o si un fallo es “aceptable” para el go.
Contexto mínimo útil: plan de release con sus fases, lista de flags con defaults, consultas de conteo previas y posteriores, runbook de rollback con tiempos medidos y las condiciones del go escritas.
Cómo verificar: cada consulta del agente se ejecuta sobre la copia, con el conteo esperado escrito antes. Cada afirmación sobre tiempos se mide en staging. El borrador del runbook se revisa contra el plan aprobado, línea por línea.
Prompt de ejemplo:
Actuá como revisor de un release de PostgreSQL para Nexo B2B.
Te paso: la migración 0005, el backfill por lotes y la lista de consultas de perfilado.
Para cada operación respondé:
1. Si toma un lock que bloquea escrituras y durante cuánto tiempo, según el tamaño de la tabla indicado.
2. Si la versión 4 de la aplicación puede seguir escribiendo después de aplicarla.
3. Una consulta de conteo que demuestre el resultado, con el número esperado.
No decidas el tratamiento de ningún registro histórico inválido. Listá esos casos como preguntas para negocio.
Al revisar la salida, marcá qué afirmaciones se verificaron en staging y cuáles siguen siendo hipótesis. Una respuesta del agente que dice “esto bloquea 3 segundos” sin medición no es evidencia: es el punto donde empieza el ensayo.
¿Cómo valoras el contenido técnico de esta lección?
Tu feedback nos ayuda a mejorar los laboratorios prácticos de Eje Z.