Módulo 5: Dependencias entre workflows

8. Proyecto: coordinar tres workflows dependientes

Descripción

Al terminar esta lección vas a haber construido, con tus propias manos, el sistema completo que este módulo venía preparando: un director (order-triage), un outbox, y un relay idempotente que coordinan los tres sub-workflows dependientes de Cumbre —check-credit, issue-refund e inventory-sync— y, sobre todo, vas a probar que una caída a mitad de la cadena no duplica ni pierde efectos al reintentar. El entregable son dos cosas concretas y defendibles: el grafo de dependencias del sistema (lo que aprendiste a dibujar en la lección 3) y el sistema corriendo, con la evidencia —consultas al ledger y al outbox— de que sobrevive a los tres desastres de la lección 1.

Esto importa porque es donde todo deja de ser teoría. Puedes entender el patrón outbox leyéndolo; lo vas a creer cuando mates una ejecución a la mitad, la reintentes, y consultes la base para ver que el reembolso se emitió exactamente una vez. Ese momento —ver con tus ojos que el sistema se recuperó sin duplicar— es lo que convierte el conocimiento en confianza. Y es, además, un entregable de portafolio: un sistema multi-workflow que puedes mostrar en una entrevista diciendo "esto sobrevive a disparos duplicados y a caídas parciales, y aquí está la prueba".

Conexión con el módulo: este proyecto es la integración de las ocho lecciones. Dibujas el grafo (lección 3), decides qué se orquesta y qué va por outbox (lecciones 2 y 6), proteges el fan-out de inventario con claves por línea (lección 4), preservas el orden con la antigüedad de las comandas (lección 5), y todo se apoya en la idempotencia (Módulo 2), los contratos (Módulo 3) y el ledger (Módulo 4). No hay nada nuevo que aprender aquí; hay todo que juntar. Si algo de lo que sigue no te queda claro, la lección que lo enseña está señalada para que vuelvas.

El encargo

Vas a construir el sistema de coordinación de reembolsos e inventario de Cumbre. La regla de negocio es sencilla:

Cuando entra un pedido, se consulta el crédito del cliente. Si el crédito no alcanza, se emite un reembolso del anticipo. Si el crédito alcanza, se descuenta el inventario de cada línea del pedido. En todos los casos, el sistema debe procesar cada pedido exactamente una vez en sus efectos, aunque el webhook se dispare dos veces y aunque una ejecución se caiga a la mitad.

Cuatro piezas:

  1. order-triage — el director. Recibe el pedido, deduplica, clasifica, consulta el crédito de forma síncrona, y decide los efectos anotándolos en el outbox de forma atómica. No ejecuta efectos él mismo.
  2. check-credit — sub-workflow de lectura. Consulta el crédito y devuelve el resultado. Se llama de forma síncrona porque el director necesita su respuesta para decidir.
  3. outbox-relay — el relay. Lee las comandas pendientes del outbox y ejecuta cada efecto de forma idempotente, llamando a issue-refund o inventory-sync con la clave de idempotencia.
  4. issue-refund e inventory-sync — sub-workflows de efecto, idempotentes: verifican el ledger antes de actuar y registran lo hecho.

Un recordatorio de las reglas de n8n 2.0 que vas a respetar todo el proyecto: los efectos externos (la pasarela, el sistema de bodega) los hacen nodos HTTP Request o —en el laboratorio local— un nodo Postgres que simula el sistema externo; nunca un nodo Code, que no puede hacer HTTP ni tocar la base. El nodo Code, si lo usas, es solo para construir claves o clasificar, con crypto o moment como únicos módulos disponibles. Las escrituras al ledger y al outbox las hace el nodo Postgres. El agente/clasificador decide; los nodos dedicados ejecutan.

Parte 1 — El grafo de dependencias (primer entregable)

Antes de tocar un nodo, dibuja el grafo. Es la mitad del entregable y es lo que te va a guiar el montaje. Con la notación de la lección 3:

                         order-triage
                    (recibe, deduplica, decide)
              │                                   │
              │ (sync)                            │ (anota en outbox — no espera)
              ▼                                   ▼
        check-credit [L]                    ┌─── outbox (tabla) ───┐
        (lee crédito)                       │  comandas pendientes  │
                                            └───────────┬───────────┘
                                                        │ (Schedule Trigger)
                                                        ▼
                                                  outbox-relay
                                          (lee comandas, ejecuta idempotente)
                                            │                      │
                                            │ (sync, con           │ (sync, con
                                            │  Idempotency-Key)    │  Idempotency-Key)
                                            ▼                      ▼
                                      issue-refund [E]      inventory-sync [E]
                                      (mueve dinero)        (cambia stock)

Recursos compartidos (dependencias ocultas):
  ⚠ ledger/outbox Postgres  → los cinco workflows dependen de él
  ⚠ pasarela de pago         → issue-refund (y el cobro original)

Lee tu propio grafo antes de seguir, porque ya te dice cómo se comporta el sistema:

  • order-triagecheck-credit es una flecha síncrona a una lectura: el director espera el resultado para decidir. Segura de repetir; riesgo de cascada si check-credit se atrasa.
  • order-triageoutbox no es una llamada: es una escritura atómica. El director anota la comanda y sigue, sin esperar a ningún efecto. Aquí es donde se corta la cascada: el director nunca se bloquea esperando a la pasarela.
  • El outbox-relay es quien toca los efectos, de forma síncrona y con clave de idempotencia. Es el único que llama a issue-refund e inventory-sync.

Fíjate en la forma general: la parte de decisión (arriba) es rápida y no toca efectos; la parte de ejecución (abajo, vía relay) toca los efectos y es idempotente. Esa separación es el patrón outbox, dibujado. Guarda este diagrama: es lo primero que mostrarías al explicar el sistema.

Parte 2 — El modelo de datos

Dos tablas en tu Postgres local (del Starter Kit del Módulo 4). Puedes usar dos tablas o unificar; aquí las mantengo separadas para que se lean claras.

El ledger, la memoria compartida de qué efectos ya ocurrieron:

CREATE TABLE ledger (
  effect_key   TEXT PRIMARY KEY,           -- la clave de idempotencia del efecto
  order_id     TEXT NOT NULL,
  effect_type  TEXT NOT NULL,              -- 'refund' | 'inventory'
  status       TEXT NOT NULL,              -- 'done'
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

El outbox, el riel de comandas:

CREATE TABLE outbox (
  id               BIGSERIAL PRIMARY KEY,
  aggregate_id     TEXT NOT NULL,          -- order_id
  effect_type      TEXT NOT NULL,          -- 'issue_refund' | 'inventory_sync'
  payload          JSONB NOT NULL,
  idempotency_key  TEXT NOT NULL UNIQUE,   -- la misma que irá al ledger
  status           TEXT NOT NULL DEFAULT 'pending',   -- pending | processing | done
  attempts         INT NOT NULL DEFAULT 0,
  created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  processed_at     TIMESTAMPTZ
);

Y una tabla que simula los sistemas externos —la pasarela y la bodega— para que en el laboratorio local, a costo cero, puedas verificar "el efecto ocurrió N veces" contando filas. En producción, esto sería la API real; aquí es tu forma de ver la verdad:

-- Simula el mundo exterior: cada fila es un efecto que "salió al mundo".
-- Contar filas aquí = contar cuántas veces ocurrió de verdad un efecto.
CREATE TABLE external_effects_log (
  id           BIGSERIAL PRIMARY KEY,
  effect_key   TEXT NOT NULL,             -- SIN unique: queremos poder ver duplicados si los hubiera
  effect_type  TEXT NOT NULL,
  order_id     TEXT NOT NULL,
  occurred_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

Ese external_effects_log es tu instrumento de medición. La prueba de todo el proyecto va a ser: cuenta las filas de external_effects_log para un pedido; deben ser exactamente las que corresponden, ni una más, pase lo que pase.

Parte 3 — Los sub-workflows de efecto, idempotentes

Cada efecto verifica el ledger antes de actuar. Son la pieza del Módulo 4, y son idénticos en estructura; muestro issue-refund y el patrón se repite para inventory-sync.

# Sub-workflow: issue-refund
#   Trigger: Execute Sub-workflow Trigger (Define using fields below)
#   Contrato de entrada: order_id, amount, currency, effect_key

1. Postgres — ¿ya está hecho?
     SELECT 1 FROM ledger WHERE effect_key = {{ $json.effect_key }};

2. IF (existe fila) → devolver { status: 'already_done' }  y terminar.

3. (no existe) Efecto real: registrar en external_effects_log
     INSERT INTO external_effects_log (effect_key, effect_type, order_id)
     VALUES ({{ $json.effect_key }}, 'refund', {{ $json.order_id }});
   -- En producción, aquí iría un HTTP Request a la pasarela con
   -- Idempotency-Key = effect_key. El nodo Code NO puede hacerlo.

4. Postgres — anotar en el ledger que quedó hecho
     INSERT INTO ledger (effect_key, order_id, effect_type, status)
     VALUES ({{ $json.effect_key }}, {{ $json.order_id }}, 'refund', 'done')
     ON CONFLICT (effect_key) DO NOTHING;

5. Devolver { status: 'done' }.

inventory-sync es igual, con effect_type = 'inventory' y su propio effect_key por línea. Nota la doble protección: la verificación del paso 1-2 (verificar y actuar) evita el trabajo, y el ON CONFLICT del paso 4 es la red por si dos ejecuciones pasan la verificación a la vez —la trampa de "verificar y luego actuar" del Módulo 2, cubierta por la restricción UNIQUE de la base—. Para el laboratorio esto basta; en producción, la Idempotency-Key de la API real es la red definitiva del lado del efecto externo.

Parte 4 — El director (order-triage)

El director recibe, deduplica, clasifica, consulta el crédito, y decide los efectos anotándolos en el outbox de forma atómica.

# Workflow: order-triage
#   Trigger: Webhook (recibe el pedido de Cumbre)

1. Postgres — deduplicar el evento (Módulo 4)
     ¿ya procesamos este event_id? Si sí, responder 200 y terminar.
     (esto cubre el webhook que se dispara dos veces por el mismo evento)

2. AI Agent / Code — clasificar el pedido
     (aquí decides prioridad/categoría; para el proyecto, lo que importa
      es que después de esto tienes order_id, customer_id, amount, line_items)

3. Execute Sub-workflow — check-credit   (SÍNCRONO: Wait for Completion = ON)
     Inputs: order_id, customer_id, amount
     Devuelve: credit_ok (true/false)

4. IF — {{ $json.credit_ok }}

   ── rama false (no hay crédito): decidir un reembolso ──
   5a. Postgres (ATÓMICO) — anotar la comanda del reembolso en el outbox
        INSERT INTO outbox (aggregate_id, effect_type, payload, idempotency_key, status)
        VALUES ('ORD-2041', 'issue_refund',
                '{"amount":2154.00,"currency":"MXN"}'::jsonb,
                'refund:ORD-2041', 'pending')
        ON CONFLICT (idempotency_key) DO NOTHING;

   ── rama true (hay crédito): decidir el descuento de inventario ──
   5b. Split Out — separar line_items en un item por línea
   5c. Postgres (ATÓMICO) — anotar UNA comanda por línea en el outbox
        INSERT INTO outbox (aggregate_id, effect_type, payload, idempotency_key, status)
        VALUES ('ORD-2041', 'inventory_sync',
                '{"sku":"CF-ARA-500","quantity":12}'::jsonb,
                'inventory:ORD-2041:CF-ARA-500', 'pending')
        ON CONFLICT (idempotency_key) DO NOTHING;
        -- (se repite por cada línea; el Split Out hace que este nodo
        --  corra una vez por línea)

6. Responder 200 al proveedor y terminar.

Los puntos que hacen esto correcto, cada uno de una lección:

  • Paso 1, deduplicación: cubre el webhook disparado dos veces por el mismo event_id (Módulo 4). Si el mismo evento llega dos veces, el segundo se detiene aquí.
  • Paso 3, síncrono: el director espera a check-credit porque necesita su resultado para el IF. Es la orquestación de la lección 2, correcta aquí porque hay una decisión sobre un resultado.
  • Pasos 5a/5c, ON CONFLICT DO NOTHING: hacen idempotente la decisión. Si el director corriera dos veces para el mismo pedido (y de algún modo se saltara la deduplicación), no anotaría comandas duplicadas: la clave UNIQUE del outbox las rechaza. Es la segunda línea de defensa detrás de la deduplicación.
  • El director no ejecuta efectos. Anota comandas y responde. No espera a la pasarela ni a la bodega. Si se cae después del paso 5, las comandas ya están en el riel, a salvo. Aquí se corta la cascada.

Parte 5 — El relay (outbox-relay)

El relay corre solo, con un Schedule Trigger, y drena el outbox.

# Workflow: outbox-relay
#   Trigger: Schedule Trigger (cada 10 segundos, por ejemplo)

1. Postgres — tomar comandas pendientes (las más viejas primero)
     SELECT * FROM outbox
     WHERE status = 'pending'
        OR (status = 'processing' AND processed_at IS NULL
            AND created_at < now() - interval '2 minutes')  -- recuperar atascadas
     ORDER BY created_at
     LIMIT 10
     FOR UPDATE SKIP LOCKED;

2. Postgres — marcar 'processing'
     UPDATE outbox SET status = 'processing', attempts = attempts + 1
     WHERE id = {{ $json.id }};

3. Switch — según effect_type:
     'issue_refund'   → Execute Sub-workflow: issue-refund   (SÍNCRONO)
     'inventory_sync' → Execute Sub-workflow: inventory-sync (SÍNCRONO)
   Inputs que se pasan: order_id (= aggregate_id), el payload desplegado,
   y effect_key = idempotency_key.

4. Postgres — marcar 'done'
     UPDATE outbox SET status = 'done', processed_at = now()
     WHERE id = {{ $json.id }};

El relay hereda toda la robustez de la lección 6:

  • FOR UPDATE SKIP LOCKED: si corres dos relays para más capacidad, no toman la misma comanda.
  • La recuperación de processing atascadas (paso 1): una comanda que quedó en processing por una caída del relay se retoma tras dos minutos. Seguro, porque el efecto es idempotente.
  • El orden de 3 y 4 (efecto, luego marcar done): es el "Orden B" que sería peligroso, pero aquí es correcto porque el efecto es idempotente. Si el relay se cae entre 3 y 4, la comanda vuelve como atascada, se reejecuta el efecto, issue-refund ve el ledger ya escrito y no vuelve a insertar en external_effects_log, y marca done. Exactamente una vez.
  • ORDER BY created_at: procesa por antigüedad, preservando el orden en que se decidieron los efectos.

Parte 6 — La prueba (el segundo entregable, y el que convence)

Aquí está el corazón del proyecto. Construir el sistema es la mitad; probar que sobrevive es lo que lo hace un entregable de dueño de sistema. Tres pruebas, cada una contra uno de los desastres. La verdad la mides contando filas en external_effects_log.

Prueba A — El camino feliz

Manda un pedido con crédito insuficiente (para que se decida un reembolso). Deja correr el relay.

Qué esperar. El outbox tiene una comanda refund:ORD-2041 que pasa de pending a processing a done. El ledger tiene una fila refund:ORD-2041. Y la verdad:

SELECT count(*) FROM external_effects_log WHERE order_id = 'ORD-2041' AND effect_type = 'refund';
-- Esperado: 1

Un reembolso. El sistema funciona en el caso normal. Repite con un pedido de crédito suficiente y verifica que hay exactamente una fila de inventory por cada línea del pedido.

Prueba B — El webhook duplicado

Dispara el mismo evento dos veces (mismo event_id, mismo order_id), simulando el proveedor que reintenta o el doble clic. Deja correr el relay.

Qué esperar. El segundo disparo se detiene en el paso 1 del director (deduplicación por event_id) y no anota una segunda comanda. Aunque se saltara la deduplicación, el ON CONFLICT del outbox rechazaría la comanda duplicada (misma idempotency_key). El resultado:

SELECT count(*) FROM outbox WHERE idempotency_key = 'refund:ORD-2041';
-- Esperado: 1  (una sola comanda, aunque el evento llegó dos veces)

SELECT count(*) FROM external_effects_log WHERE order_id = 'ORD-2041' AND effect_type = 'refund';
-- Esperado: 1  (un solo reembolso)

El desastre 3 —el efecto duplicado por doble disparo— no ocurrió. Dos capas lo evitaron: la deduplicación del evento y la unicidad de la comanda.

Prueba C — La caída a mitad de la cadena (la prueba estrella)

Esta es la que demuestra el valor del outbox. Vas a matar el relay después de que el efecto se ejecutó pero antes de que marque la comanda como done, y luego dejarlo reintentar.

Cómo provocarla. En issue-refund, entre el paso 3 (insertar en external_effects_log) y el paso 4 (anotar en el ledger), detén la ejecución a la fuerza —desactiva el relay, o mata la ejecución desde el panel, o pon temporalmente un nodo que falle justo ahí—. El objetivo es dejar el mundo en este estado inconsistente a propósito:

  • external_effects_log: tiene la fila del reembolso (el efecto ya salió).
  • ledger: no tiene la fila (no alcanzó a anotar).
  • outbox: la comanda quedó en processing.

Verifica ese estado intermedio:

SELECT count(*) FROM external_effects_log WHERE effect_key = 'refund:ORD-2041';  -- 1
SELECT count(*) FROM ledger WHERE effect_key = 'refund:ORD-2041';                -- 0
SELECT status FROM outbox WHERE idempotency_key = 'refund:ORD-2041';             -- processing

Ese es exactamente el "Orden B roto" del principio de la lección 6: el efecto ocurrió, pero el sistema no lo registró. Sin idempotencia, reintentar aquí duplicaría el reembolso. Ahora reactiva el relay y déjalo correr.

Qué esperar al reintentar. El relay recupera la comanda atascada en processing (la lógica del paso 1). Vuelve a llamar a issue-refund. issue-refund ejecuta su paso 1 —consultar el ledger— y... el ledger todavía dice que no está hecho (el paso 4 nunca corrió la primera vez). Aquí es donde importa la doble protección: el sub-workflow va a intentar el efecto de nuevo. Pero fíjate en el diseño: en el laboratorio, la red que evita el duplicado es el orden de las operaciones dentro de issue-refund. Para que la Prueba C demuestre exactamente-una-vez, issue-refund debe anotar en el ledger en la misma operación atómica que ejecuta el efecto simulado, o usar la Idempotency-Key contra un external_effects_log con clave única. Ajústalo así:

-- issue-refund, versión a prueba de la caída intermedia:
-- efecto y registro en el ledger, atómicos, con la clave como candado.
WITH done AS (
  INSERT INTO ledger (effect_key, order_id, effect_type, status)
  VALUES ({{ $json.effect_key }}, {{ $json.order_id }}, 'refund', 'done')
  ON CONFLICT (effect_key) DO NOTHING       -- si ya estaba, no hace nada
  RETURNING effect_key
)
INSERT INTO external_effects_log (effect_key, effect_type, order_id)
SELECT {{ $json.effect_key }}, 'refund', {{ $json.order_id }}
FROM done;                                   -- solo registra el efecto si el ledger fue nuevo

Con issue-refund así, la caída intermedia deja el mundo consistente: o se hicieron las dos escrituras (ledger + efecto) o ninguna, porque están en una sola transacción. Reintentar es seguro:

-- Después de reintentar:
SELECT count(*) FROM external_effects_log WHERE effect_key = 'refund:ORD-2041';  -- 1
SELECT count(*) FROM ledger WHERE effect_key = 'refund:ORD-2041';                -- 1
SELECT status FROM outbox WHERE idempotency_key = 'refund:ORD-2041';             -- done

Exactamente un reembolso, a pesar de la caída. El sistema se recuperó: la comanda estaba a salvo en el riel, el reintento la completó, y la atomicidad del efecto-más-ledger garantizó que no se duplicara. Esa consulta que devuelve 1 es la prueba de todo el módulo. Es lo que muestras cuando alguien te pregunta "¿y cómo sé que esto no cobra dos veces?".

Una aclaración honesta sobre la simulación. En el laboratorio, la atomicidad efecto-más-ledger es fácil porque el "efecto" es una fila en tu propia base. Con una pasarela real, el efecto vive en otra casa y no cabe en tu transacción —es el problema de la doble escritura otra vez—. Ahí la red es la Idempotency-Key que mandas a la pasarela: reintentar llama a la pasarela con la misma clave, y ella deduplica. El laboratorio demuestra el mecanismo de coordinación (outbox + relay + ledger); la Idempotency-Key de la API real es lo que traslada la garantía al efecto externo. Las dos piezas trabajan juntas, como viste en la lección 6.

Errores comunes

Ejecutar el efecto en el director "para no montar el relay" (conceptual). Qué pasa: se arma order-triage para que, tras decidir, llame directo a issue-refund de forma síncrona, saltándose el outbox y el relay. Funciona en las pruebas A y B, y falla la C: una caída entre decidir y ejecutar pierde o duplica el efecto. Por qué pasa: el relay se siente como una pieza extra, y llamar al sub-workflow directo es más rápido de montar. Cómo detectarlo: si tu director tiene un Execute Sub-workflow hacia un efecto, en vez de un INSERT al outbox, no implementaste el patrón. Cómo corregirlo: el director decide y anota; el relay ejecuta. La Prueba C es la que revela si de verdad separaste las dos cosas —si la fallas, el efecto y la decisión todavía están pegados—.

Poner la comanda y la decisión en nodos Postgres separados (práctico). Qué pasa: el director cambia un estado en un nodo y anota la comanda en otro; una caída entre ambos deja el sistema inconsistente, y el outbox no cumple su promesa porque su premisa —la escritura atómica— se rompió. Por qué pasa: un nodo por operación es el reflejo natural en n8n. Cómo detectarlo: si la decisión y la comanda están en nodos distintos, no son atómicas. Cómo corregirlo: una sola sentencia o transacción —o, como en este proyecto, deja que la comanda del outbox sea el registro de la decisión, y entonces es una sola inserción, trivialmente atómica—.

Usar una clave de idempotencia por línea igual para todas (práctico). Qué pasa: al anotar las comandas de inventario, la clave se construye solo con el order_id, así que las tres líneas comparten inventory:ORD-2041 y el ON CONFLICT deja pasar solo la primera; se descuenta una línea y se pierden dos. Por qué pasa: es el error de granularidad de la lección 4, aquí en el momento de anotar comandas. Cómo detectarlo: si un pedido de tres líneas genera una sola comanda de inventario, la clave es demasiado gruesa. Cómo corregirlo: la clave del fan-out lleva el sku (inventory:ORD-2041:CF-ARA-500); una comanda por unidad de trabajo que debe ocurrir una vez.

Probar solo el camino feliz y declarar el proyecto terminado (conceptual). Qué pasa: se corre la Prueba A, todo sale bien, y se da por completo el sistema sin correr la B ni la C. En producción, el primer webhook duplicado o la primera caída revela que la coordinación no estaba blindada. Por qué pasa: la Prueba A es la satisfactoria —todo verde— y las otras dos dan trabajo. Cómo detectarlo: si no mataste una ejecución a la mitad y la reintentaste, no probaste lo que este módulo enseña. Cómo corregirlo: las pruebas B y C son el entregable; la A solo confirma que el sistema hace algo. Un dueño de sistema entrega la evidencia de que sobrevive a lo que sale mal, no de que funciona cuando todo sale bien.

Ejercicios

Ejercicio 1 — Agrega la prueba del fan-out con caída parcial. Diseña una cuarta prueba (Prueba D) que combine el fan-out de inventario con una caída parcial: un pedido de tres líneas donde el relay procesa dos comandas de inventario y se cae antes de la tercera. Di qué estado esperas en cada tabla justo después de la caída, y qué esperas tras reintentar, con las consultas SQL que lo verifican.

Ver solución

Estado justo después de la caída (procesó las líneas 1 y 2, cayó antes de la 3):

-- external_effects_log: dos efectos de inventario para el pedido
SELECT count(*) FROM external_effects_log
WHERE order_id = 'ORD-2041' AND effect_type = 'inventory';   -- 2

-- outbox: dos comandas 'done', una 'pending' o 'processing'
SELECT idempotency_key, status FROM outbox
WHERE aggregate_id = 'ORD-2041' AND effect_type = 'inventory_sync';
-- inventory:ORD-2041:CF-ARA-500  → done
-- inventory:ORD-2041:TE-CHM-100  → done
-- inventory:ORD-2041:CF-DEC-250  → pending/processing

Tras reintentar (el relay retoma la comanda pendiente/atascada):

SELECT count(*) FROM external_effects_log
WHERE order_id = 'ORD-2041' AND effect_type = 'inventory';   -- 3, ni una más

SELECT count(*) FROM outbox
WHERE aggregate_id = 'ORD-2041' AND effect_type = 'inventory_sync' AND status = 'done';  -- 3

Lo que demuestra: el reintento completó solo la tercera comanda —las dos primeras ya estaban done y el relay no las volvió a tomar—, así que cada línea se descontó exactamente una vez. Es la cura del reintento parcial de la lección 4, ahora con el outbox: las comandas ya hechas no se re-ejecutan porque su estado es done, y aunque una atascada se reejecutara, su clave por línea la protege. Tres líneas, tres efectos, cero duplicados, a pesar de la caída a la mitad del fan-out.

Por qué funciona: uniste el fan-out (lección 4) con el outbox (lección 6) y lo probaste con la disciplina de la Prueba C —provocar el estado intermedio y verificar por conteo—. Esta Prueba D es, en realidad, la más completa del proyecto: cubre fan-out y caída parcial a la vez.

Ejercicio 2 — Endurece contra dos relays. Quieres correr dos instancias del outbox-relay a la vez para más capacidad. Explica qué parte del diseño ya lo permite sin duplicar efectos, qué podría salir mal si esa parte no estuviera, y cómo lo verificarías corriendo los dos relays contra una ráfaga de comandas.

Ver solución

La parte que ya lo permite es el FOR UPDATE SKIP LOCKED en la consulta que toma las comandas (paso 1 del relay). Cuando el relay 1 selecciona un lote de comandas pending, esas filas quedan bloqueadas dentro de su transacción; cuando el relay 2 corre su misma consulta al mismo tiempo, SKIP LOCKED le hace saltarse las filas ya bloqueadas y tomar otras distintas. Los dos relays trabajan comandas diferentes; ninguno toca la del otro.

Qué saldría mal sin esa cláusula: sin SKIP LOCKED, los dos relays podrían seleccionar la misma comanda pending en el mismo instante, y los dos llamarían al efecto para ella. Aquí la segunda red —la idempotencia del efecto por su clave— evitaría que se duplicara el reembolso de verdad (el effect_key en el ledger lo dedup­lica), pero se desperdiciaría trabajo: dos ejecuciones del sub-workflow, dos llamadas, para una comanda. Y en el peor caso, si el efecto no fuera idempotente, sí habría duplicado. SKIP LOCKED es lo que evita el solapamiento en el origen; la idempotencia es la red por si algo se cuela.

Cómo verificarlo: mete una ráfaga de, digamos, 50 comandas al outbox de golpe, arranca los dos relays a la vez, deja que dren­en, y cuenta:

-- Ninguna comanda quedó sin procesar
SELECT count(*) FROM outbox WHERE status <> 'done';        -- 0

-- Cada efecto ocurrió exactamente una vez (sin duplicados por solapamiento)
SELECT effect_key, count(*) FROM external_effects_log
GROUP BY effect_key HAVING count(*) > 1;                    -- 0 filas (ningún duplicado)

Si la segunda consulta devuelve alguna fila, dos relays procesaron la misma comanda y solo la idempotencia te salvó; si devuelve cero, SKIP LOCKED repartió bien el trabajo. Las dos capas juntas —reparto sin solapamiento e idempotencia— son lo que hace seguro escalar el relay.

Por qué funciona: identificaste que la coordinación entre relays vive en la base de datos (SKIP LOCKED), no en n8n, y que la idempotencia es la red detrás de esa coordinación. Escalar el relay es exactamente el tipo de cambio que un dueño de sistema hace con confianza solo cuando puede nombrar qué lo protege.

Ejercicio 3 — Defiende el sistema en una entrevista. Imagina que presentas este proyecto y alguien te pregunta: "¿Cómo sé que si el proveedor dispara el webhook tres veces y encima tu servidor se cae a la mitad, al cliente no se le emite el reembolso más de una vez?". Responde en un párrafo, nombrando las piezas concretas que dan la garantía y en qué orden actúan.

Ver solución

Una respuesta sólida recorre las capas de defensa en orden, de la entrada al efecto:

"Hay tres capas, y cada una cubre un camino distinto del duplicado. Primero, la deduplicación por event_id en el director: si el webhook se dispara tres veces con el mismo evento, solo el primero pasa; los otros dos se detienen antes de decidir nada. Segundo, la decisión idempotente: aunque un duplicado se colara, la comanda del outbox tiene la clave refund:ORD-2041 marcada como única, así que no se anotan dos comandas para el mismo reembolso —hay una sola intención en el riel—. Tercero, la ejecución idempotente en el relay: el efecto se registra en el ledger en la misma operación atómica en que ocurre, con la clave como candado, así que si el servidor se cae después de emitir el reembolso pero antes de anotarlo, al reintentar el ON CONFLICT reconoce que ya está hecho y no lo emite de nuevo; con una pasarela real, la Idempotency-Key hace ese mismo trabajo del lado de la API. El resultado es que el reembolso ocurre exactamente una vez sin importar cuántas veces llegue el webhook ni dónde se caiga el sistema, y te lo puedo demostrar: aquí está la consulta que cuenta los reembolsos de ese pedido, y da uno."

Lo que hace fuerte esta respuesta: no dice "confía en que funciona", nombra las piezas (event_id dedup, clave única del outbox, atomicidad efecto-más-ledger / Idempotency-Key) y las conecta con los caminos concretos del duplicado (webhook repetido, decisión doble, caída intermedia). Y cierra ofreciendo la evidencia —la consulta que devuelve 1—, que es lo que separa a un dueño de sistema de alguien que espera que las cosas salgan bien.

Por qué funciona: poder defender el sistema con precisión, capa por capa, y respaldarlo con una consulta verificable, es el objetivo final de la guía entera. Si puedes dar esta respuesta, el "constructor de workflows vs dueño del sistema" del Módulo 1 ya se resolvió a tu favor.

Resumen y siguiente paso

En esta lección construiste y probaste el sistema completo que el módulo venía preparando: un director (order-triage) que recibe, deduplica, consulta el crédito de forma síncrona y decide los efectos anotándolos en un outbox de forma atómica; un relay (outbox-relay) que drena el outbox y ejecuta cada efecto de forma idempotente vía issue-refund e inventory-sync; y el ledger como memoria compartida de qué ya ocurrió. El primer entregable es el grafo de dependencias, que muestra la separación entre la parte de decisión (rápida, sin efectos) y la de ejecución (idempotente, vía relay). El segundo, el que convence, son las pruebas: el camino feliz (Prueba A), el webhook duplicado que no duplica efectos (Prueba B), la caída a mitad de la cadena que se recupera sin duplicar ni perder (Prueba C), y el fan-out con caída parcial (Prueba D del ejercicio). En todas, la verdad se mide igual —contando filas en external_effects_log— y en todas el resultado es el mismo: cada efecto, exactamente una vez.

Con esto cierras el Módulo 5. Sabes coordinar varios workflows dependientes sin caer en los tres desastres: mapeas el sistema con el grafo, eliges entre orquestación y coreografía, repartes y juntas trabajo sin perder items, controlas el ritmo y el orden, y —la pieza que lo integra todo— separas decidir de ejecutar con el patrón outbox, incluso cuando quien decide es un agente. La capacidad de salida del módulo está cumplida: puedes mapear el grafo de dependencias de un sistema multi-workflow y coordinar sus piezas con el outbox y el queue mode sin cascadas, sin perder items y sin duplicar efectos.

Lo que queda para el Módulo 6 es la última capa de robustez: qué hacer cuando, a pesar de todo, algo falla de verdad. Vas a aprender a reintentar sin duplicar apoyándote en la idempotencia que ya tienes, a diseñar acciones compensatorias para deshacer lo que no se puede evitar repetir, a decidir qué fallo merece una alerta y cuál se recupera solo, a enrutar los fallos reales a una cola de mensajes muertos con un Error Trigger, y a reproducir un bug de duplicado con el motor de replay de n8n 2.0. Es la diferencia entre un sistema que es correcto cuando todo va bien y uno que, además, se recupera con gracia cuando algo va mal —y sabe pedir ayuda cuando de verdad la necesita—.

Recursos

  • Execute Sub-workflow Trigger — n8n Docs — el disparador con el que declaras el contrato de entrada de check-credit, issue-refund e inventory-sync (con Define using fields below) y donde el último nodo define la respuesta al que llamó.
  • Execute Sub-workflow node — n8n Docs — el nodo con el que el director llama a check-credit de forma síncrona y con el que el relay ejecuta los efectos, pasando la clave de idempotencia en el encargo.
  • Postgres node — n8n Docs — el nodo de todas las escrituras atómicas del proyecto: la comanda en el outbox, el efecto-más-ledger, y la toma de comandas con FOR UPDATE SKIP LOCKED.
  • Schedule Trigger — n8n Docs — el disparador del relay, que revisa el outbox cada pocos segundos.
  • Split Out node — n8n Docs — el nodo que separa line_items en un item por línea, para anotar una comanda de inventario por sku.
  • Self-Hosted AI Starter Kit — n8n Docs — el paquete con Postgres local (y modelo de IA) donde corres todo este proyecto a costo cero; verifica en la doc la versión vigente al montarlo.