Módulo 5: Dependencias entre workflows

6. El patrón outbox: decidir y ejecutar por separado

Descripción

Al terminar esta lección vas a poder explicar y aplicar el patrón outbox, la técnica que hace robusta toda la coordinación de este módulo. La idea, en una frase: en vez de decidir un efecto y ejecutarlo en el mismo paso, separas las dos cosas —anotas la intención del efecto en una tabla outbox, en la misma operación atómica en que registras tu decisión, y un flujo aparte lee esa tabla y ejecuta el efecto de forma idempotente—. Vas a entender el problema exacto que esto resuelve —el problema de la doble escritura, que ningún reintento por sí solo arregla—, vas a ver la anatomía de la tabla outbox y de los dos workflows que la usan (el que decide y el que ejecuta, llamado relay), y vas a poder razonar qué pasa ante una caída en cada punto de la cadena y por qué en ninguno se pierde ni se duplica el efecto.

Esto importa porque es la pieza que faltaba. Hasta aquí sabes hacer un efecto idempotente (Módulo 2), tienes un ledger (Módulo 4), sabes repartir y juntar (lección 4) y controlar el ritmo (lección 5). Pero queda una grieta que ninguna de esas piezas tapa sola: el instante entre "decidí que hay que hacer un efecto" y "el efecto ocurrió". Si el sistema se cae en ese instante, ¿el efecto se hizo o no?, ¿se va a hacer al reintentar o se perdió?, ¿o se va a hacer dos veces? El outbox es la respuesta a esa pregunta, y es lo que convierte una coordinación que "casi siempre funciona" en una que es correcta por construcción.

Conexión con el módulo: esta lección junta todo lo anterior. El desacople recibir/procesar que asomó en la lección 5 es, formalizado, el outbox. La idempotencia del Módulo 2 es lo que hace segura la ejecución del relay. El ledger del Módulo 4 es, muchas veces, la misma tabla que el outbox. El grafo de la lección 3 gana una forma nueva de arista —"anota en el outbox" en vez de "llama y espera"— que corta cascadas. Y la lección 7 va a aplicar exactamente este patrón a los efectos que dispara un agente. Si una sola lección de este módulo hay que dominar, es esta.

El problema que ningún reintento arregla solo

Vamos a nombrar con precisión la grieta, porque es sutil y es la razón de ser del patrón.

Cuando order-triage decide emitir un reembolso, en el fondo tiene que hacer dos cosas: (1) registrar en su propio sistema que ese pedido queda reembolsado —cambiar un estado, en el ledger o en el CRM—, y (2) ejecutar el efecto de verdad —llamar a la pasarela de pago para que devuelva el dinero—. Dos escrituras: una en tu casa (tu base de datos) y otra en casa ajena (la pasarela). Y aquí está el problema: no existe forma de hacer esas dos escrituras como una sola operación atómica. Tu base de datos y la pasarela de pago son dos mundos separados; no puedes envolverlos en una transacción única que las haga a las dos o a ninguna.

A ese problema se le llama el problema de la doble escritura (dual write), y tiene solo dos órdenes posibles, los dos rotos:

Orden A — primero cambio el estado, luego ejecuto el efecto.

1. UPDATE: marcar el pedido como "reembolsado" en mi base   ✓
   ─── el sistema se cae aquí ───
2. HTTP: llamar a la pasarela para emitir el reembolso        ✗ nunca ocurre

Si me caigo entre el paso 1 y el 2, mi base dice "reembolsado" pero el dinero nunca se devolvió. El efecto se perdió. Y lo peor: como mi estado dice "reembolsado", ningún reintento lo va a volver a intentar —el sistema cree que ya está hecho—. El cliente no recibe su dinero y el sistema jura que sí.

Orden B — primero ejecuto el efecto, luego cambio el estado.

1. HTTP: llamar a la pasarela para emitir el reembolso        ✓ el dinero se devolvió
   ─── el sistema se cae aquí ───
2. UPDATE: marcar el pedido como "reembolsado" en mi base    ✗ nunca ocurre

Si me caigo entre el paso 1 y el 2, el dinero se devolvió pero mi base no lo sabe. Como mi estado no dice "reembolsado", el reintento va a volver a emitir el reembolso: el efecto se duplica. El cliente recibe el dinero dos veces.

Léelo despacio, porque es el corazón de la lección: no hay un orden bueno. Cualquiera de los dos, ante una caída en el momento justo, rompe algo —el A pierde el efecto, el B lo duplica—. Y ningún reintento arregla esto por sí solo, porque el reintento no sabe en qué punto te caíste. Este es el hueco que las lecciones anteriores dejaban abierto, y el outbox es lo que lo cierra.

La idea del outbox: separar decidir de ejecutar

La solución es astuta y, una vez que la ves, evidente. El problema era que "cambiar mi estado" y "ejecutar el efecto ajeno" no se pueden hacer atómicos juntos. Entonces: no los hagas juntos. Divide el trabajo en dos momentos:

  1. Decidir (atómico, todo en mi casa). Cuando order-triage decide emitir el reembolso, hace dos escrituras que sí están en mi base de datos y por lo tanto sí caben en una sola transacción atómica: cambia el estado del pedido y anota, en una tabla llamada outbox, una fila que dice "hay que emitir un reembolso para ORD-2041". Las dos escrituras son en Postgres, mi casa, así que o suceden las dos o ninguna. No llamo a la pasarela todavía. Solo registro la intención.

  2. Ejecutar (aparte, idempotente, reintentable). Un flujo separado —el relay, o despachador— lee las filas pendientes del outbox, y por cada una ejecuta el efecto de verdad —llama a la pasarela—, de forma idempotente, y marca la fila como hecha.

Fíjate qué se ganó. La decisión —"este pedido se reembolsa"— quedó registrada de forma atómica, junto con la intención del efecto, todo en mi base. No hay un instante en que "decidí reembolsar" exista sin que "hay que emitir el reembolso" también exista: nacen juntos o no nacen. Y la ejecución del efecto quedó separada, en un flujo que puede reintentar cuantas veces haga falta sin peligro, porque es idempotente. El problema de la doble escritura se disolvió: ya no hay dos escrituras en mundos distintos que deban ser atómicas; hay una escritura atómica en mi casa (decidir) y una ejecución idempotente aparte (ejecutar).

La analogía: el riel de comandas del restaurante

Imagina la cocina de un restaurante con volumen. El mesero toma tu orden. ¿Qué hace? No corre a la cocina a cocinar él mismo tu plato. Escribe la orden en una comanda —un papelito— y la clava en un riel que cuelga frente a la cocina. Ese gesto, escribir-y-clavar, es uno solo: la orden queda anotada y colgada en el mismo movimiento. Después, el mesero se va a atender otra mesa.

La cocina trabaja el riel a su ritmo. Toma la comanda más vieja, prepara el plato, y cuando sale, quita la comanda del riel o la marca como hecha. Si la cocina se satura, las comandas se acumulan en el riel —pero ninguna se pierde, están todas colgadas ahí—. Si el cocinero se distrae y no está seguro de si ya preparó un plato, mira el riel: si la comanda sigue clavada, no está hecho; si ya no está, ya salió. Y cada comanda tiene un número, así que aunque dos cocineros miren el riel, no preparan el mismo plato dos veces: el número dice cuál es cuál.

Esa cocina es el patrón outbox entero:

  • El mesero es el workflow que decide (order-triage). No ejecuta el efecto; anota la comanda (escribe en el outbox) en el mismo gesto en que registra la orden.
  • El riel es la tabla outbox. Las intenciones de efecto cuelgan ahí, pendientes, sin perderse aunque la cocina se atrase.
  • La cocina es el relay: toma las comandas pendientes y las ejecuta a su ritmo, marcándolas como hechas al terminar.
  • El número de comanda es la clave de idempotencia: garantiza que ningún plato se cocine dos veces, aunque el relay reintente.

Y el punto clave de la analogía, el que resuelve la doble escritura: anotar la comanda y ejecutarla son dos momentos separados. El mesero nunca se queda atrapado entre "tomé la orden" y "el plato está listo", porque su trabajo termina al clavar el papel. La cocina, por su lado, nunca duplica un plato, porque trabaja de un riel donde cada comanda tiene su número. Separar el que anota del que cocina es lo que hace robusto al restaurante entero.

Anatomía de la tabla outbox

La tabla outbox es el riel. Su forma típica, en Postgres:

CREATE TABLE outbox (
  id               BIGSERIAL PRIMARY KEY,       -- el número de comanda
  aggregate_id     TEXT NOT NULL,               -- a qué se refiere: el order_id
  effect_type      TEXT NOT NULL,               -- qué efecto: 'issue_refund', 'inventory_sync'
  payload          JSONB NOT NULL,              -- los datos que el efecto necesita
  idempotency_key  TEXT NOT NULL UNIQUE,        -- la clave que evita duplicar el efecto
  status           TEXT NOT NULL DEFAULT 'pending',  -- pending | processing | done | failed
  attempts         INT  NOT NULL DEFAULT 0,     -- cuántas veces se intentó ejecutar
  last_error       TEXT,                        -- el último error, si falló
  created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  processed_at     TIMESTAMPTZ                  -- cuándo se marcó hecha
);

Vamos por las columnas que importan:

  • aggregate_id es a qué se refiere el efecto: el order_id. Te deja consultar "todas las intenciones pendientes del pedido ORD-2041".
  • effect_type es qué efecto hay que ejecutar. Una misma tabla outbox puede llevar intenciones de varios tipos —reembolsos, sincronizaciones de inventario, correos— y el relay decide qué hacer según este campo.
  • payload son los datos que el efecto necesita: el monto a reembolsar, las unidades a descontar. Se guarda en el momento de decidir, para que el relay no tenga que ir a buscarlos.
  • idempotency_key es el número de comanda: la clave del Módulo 2 que garantiza que el efecto no se ejecute dos veces. Marcarla UNIQUE es una defensa extra: la base misma rechaza dos intenciones idénticas.
  • status es dónde está cada comanda: pending (recién anotada, sin ejecutar), processing (el relay la está trabajando), done (hecha) o failed (falló y necesita atención). El relay se mueve por estos estados.
  • attempts y last_error son para la operación: cuántas veces se intentó y qué salió mal, útiles para la lección de reintentos y alertas del Módulo 6.

Una observación de continuidad que ahorra trabajo: muchas veces el outbox y el ledger son la misma tabla, o tablas hermanas. Registrar "decidí reembolsar el pedido ORD-2041" en el ledger es anotar la comanda. No siempre necesitas una tabla aparte; a veces el ledger, con una columna de estado, hace de outbox. Empieza simple: si tu ledger ya registra las decisiones, agrégale el estado y ya tienes el riel.

Los dos workflows: el que decide y el relay

El patrón vive en dos workflows. Vamos a construirlos para el reembolso de Cumbre.

Workflow 1 — El que decide (order-triage)

Cuando order-triage determina que un pedido necesita reembolso, no llama a issue-refund. En su lugar, hace una escritura atómica: registra su decisión y anota la comanda en el outbox, todo en una sola operación de Postgres.

La atomicidad es el punto delicado en n8n, así que seamos concretos. El nodo de Postgres ejecuta consultas SQL; para que las dos escrituras sean atómicas, van en una sola sentencia o transacción. Si el ledger y el outbox son la misma tabla, es una sola inserción —trivialmente atómica—. Si son tablas distintas, se envuelven en una transacción. Una forma limpia con una sola sentencia, usando el ledger como fuente y el outbox como destino:

-- Nodo Postgres en order-triage: decidir el reembolso (atómico)
-- Registra la decisión en el ledger E inserta la comanda en el outbox,
-- en una sola transacción. O suceden las dos o ninguna.

WITH decision AS (
  INSERT INTO ledger (order_id, effect, status, decided_at)
  VALUES ('ORD-2041', 'refund', 'decided', now())
  ON CONFLICT (order_id, effect) DO NOTHING     -- idempotente: si ya se decidió, no repite
  RETURNING order_id
)
INSERT INTO outbox (aggregate_id, effect_type, payload, idempotency_key, status)
SELECT 'ORD-2041', 'issue_refund',
       '{"amount": 2154.00, "currency": "MXN"}'::jsonb,
       'refund:ORD-2041',                        -- la clave de idempotencia del efecto
       'pending'
FROM decision;                                   -- solo inserta la comanda si hubo decisión nueva

Lee lo que hace esa consulta, porque tiene toda la robustez adentro:

  • La primera parte registra la decisión en el ledger con ON CONFLICT DO NOTHING: si el pedido ya se había decidido reembolsar, no hace nada —idempotencia en la decisión, para que dos disparos de order-triage no anoten dos comandas—.
  • La segunda parte inserta la comanda en el outbox solo si hubo una decisión nueva (el FROM decision). Si la decisión ya existía, no se inserta comanda.
  • Las dos partes están en una sola sentencia, así que son atómicas: o se registra la decisión y se anota la comanda juntas, o no pasa nada. Nunca queda una sin la otra.

Y crucialmente: order-triage termina aquí. No esperó a la pasarela, no ejecutó el efecto, no se puede caer "entre decidir y ejecutar" porque para él no hay un "ejecutar". Anotó la comanda y siguió. Si se cae justo después, la comanda ya está clavada en el riel, a salvo.

Qué esperar. Al ejecutar order-triage con un pedido que requiere reembolso, vas a ver el nodo de Postgres insertar una fila en outbox con status = 'pending'. Consulta la tabla: ahí está la comanda, esperando. issue-refund todavía no corrió, y el dinero todavía no se movió. Eso es correcto: la decisión está registrada de forma durable, la ejecución vendrá después.

Workflow 2 — El relay (el que ejecuta)

El relay es un workflow aparte que corre solo, con un Schedule Trigger —cada pocos segundos, o el ritmo que convenga—. Su ciclo es: toma las comandas pendientes, ejecuta cada efecto, marca como hecha. Su estructura:

# Workflow: outbox-relay (Schedule Trigger, cada N segundos)

1. Postgres: tomar las comandas pendientes
     SELECT * FROM outbox
     WHERE status = 'pending'
     ORDER BY created_at            -- las más viejas primero: preserva el orden
     LIMIT 10
     FOR UPDATE SKIP LOCKED;        -- que dos relays no tomen la misma comanda

2. (por cada comanda) marcarla 'processing'
     UPDATE outbox SET status = 'processing', attempts = attempts + 1
     WHERE id = {{ $json.id }};

3. HTTP Request: ejecutar el efecto de verdad
     POST a la pasarela de pago, con Idempotency-Key = {{ $json.idempotency_key }}
     body = {{ $json.payload }}

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

Tres detalles hacen que esto sea correcto y no solo "un bucle que llama a la pasarela":

FOR UPDATE SKIP LOCKED en el paso 1. Si corres más de un relay (para capacidad), esto garantiza que dos relays no tomen la misma comanda: el primero la bloquea, el segundo la salta. Es la versión en base de datos de "dos cocineros no toman la misma comanda del riel".

El Idempotency-Key en el paso 3. El efecto se ejecuta con la clave que guardaste al anotar la comanda (refund:ORD-2041). Esto es lo que aprendiste en el Módulo 2: la pasarela, al recibir dos veces la misma Idempotency-Key, ejecuta el efecto una sola vez. Es la red de seguridad para el caso que viene ahora.

El orden de los pasos 3 y 4. Fíjate: primero se ejecuta el efecto (3), después se marca done (4). ¿No es ese el "Orden B" roto que vimos al principio —efecto y luego estado—? Sí lo es. Pero ahora está protegido por la idempotencia del efecto, y eso lo cambia todo. Veámoslo.

Por qué el relay no duplica: la caída en el peor momento

El momento peligroso del relay es entre el paso 3 (ejecutó el efecto) y el paso 4 (marcó done). Si el relay se cae ahí, la comanda quedó en processing, el reembolso ya se emitió, pero la tabla no dice done. En el siguiente ciclo, ¿qué pasa?

Depende de cómo tomes las comandas. Si el relay recupera también las que llevan mucho en processing (asumiéndolas caídas), va a volver a ejecutar el efecto del paso 3 —va a llamar a la pasarela otra vez—. Y aquí entra la red de seguridad: como usa la misma Idempotency-Key (refund:ORD-2041), la pasarela reconoce que es el mismo reembolso que ya emitió y no lo emite de nuevo. Devuelve el resultado del primero. El relay recibe esa respuesta, y ahora sí marca done. Resultado: el reembolso se emitió exactamente una vez, aunque el relay ejecutó el efecto dos veces.

Esa es la magia del patrón, y conviene decirla con todas las letras: el relay entrega el efecto "al menos una vez", y la idempotencia del efecto lo convierte en "exactamente una vez". El relay puede caerse, reintentar, ejecutar el mismo efecto varias veces —y como el efecto es idempotente por su clave, el resultado en el mundo real ocurre una sola vez—. El outbox garantiza que el efecto no se pierde (la comanda sigue en el riel hasta que se marca done); la idempotencia garantiza que no se duplica (la clave lo dedup­lica). Las dos juntas cierran la grieta.

Compáralo con el problema del principio. Antes teníamos dos escrituras en mundos distintos que no podían ser atómicas, y cualquier orden se rompía. Ahora: la decisión es atómica (una escritura en mi casa), y la ejecución es idempotente (se puede repetir sin daño). Ninguna de las dos partes tiene el problema de la doble escritura, porque las separamos justo por donde dolía.

Qué se cae, y por qué en ningún punto se rompe

Vale la pena recorrer la cadena entera y ver qué pasa si el sistema se cae en cada punto. Esta tabla es la prueba de que el patrón es correcto por construcción, no por suerte:

Se cae en...Estado del mundoQué pasa al recuperarse
Antes de anotar la comandaNo hay decisión ni comandaorder-triage se reintenta; decide y anota. Nada perdido: como si no hubiera pasado.
Justo después de anotar (comanda pending, efecto sin ejecutar)Comanda clavada en el riel, efecto sin hacerEl relay la toma en su próximo ciclo y ejecuta. El efecto ocurre, tarde pero seguro.
En el relay, entre ejecutar el efecto y marcar doneEfecto ya hecho, comanda en processingEl relay reintenta; llama a la pasarela con la misma clave; la pasarela no duplica; marca done. Efecto: exactamente una vez.
Después de marcar doneEfecto hecho, comanda doneNada que hacer. Completado.

Recórrela y fíjate en que en ninguna fila se pierde ni se duplica el efecto. Se puede atrasar —la comanda espera en el riel— pero no se pierde, porque quedó registrada de forma durable en el momento de decidir. Y se puede reintentar —el relay puede ejecutar el efecto más de una vez— pero no se duplica, porque la clave de idempotencia lo dedup­lica. Esa es la definición de coordinación robusta: no que nada falle, sino que ningún fallo rompa la correctitud.

Cómo el outbox resuelve los tres desastres

Cierra el círculo del módulo. Los tres desastres de la lección 1, todos atacados por este patrón:

  • La cascada: order-triage ya no espera a la pasarela —anota la comanda y sigue—. Un servicio de pago lento ya no bloquea la recepción de pedidos; solo hace que las comandas se acumulen en el riel, donde esperan sin causar daño. El relay las drena a su ritmo. El outbox es un amortiguador, igual que la cola de Redis de la lección 5.
  • La pérdida de orden: el relay procesa las comandas ORDER BY created_at —las más viejas primero—, así que puede aplicar los efectos en el orden en que se decidieron, aun bajo carga. Y si combinas con la versión de la lección 5, el orden queda blindado.
  • El efecto duplicado: resuelto por la idempotencia de la ejecución del relay, como acabamos de ver. Dos disparos de order-triage no anotan dos comandas (la decisión es idempotente); dos ejecuciones del relay no emiten dos reembolsos (la clave es idempotente).

Un patrón, los tres desastres. Por eso esta es la lección bisagra: no es una técnica más, es la que integra todo lo anterior en una coordinación que se sostiene.

Errores comunes

Ejecutar el efecto en el workflow que decide, "para simplificar" (conceptual). Qué pasa: alguien arma order-triage para que, al decidir el reembolso, llame directo a la pasarela en el mismo flujo —sin outbox— porque "un flujo es más simple que dos". Funciona en la demo. En producción, la primera caída entre el cambio de estado y la llamada rompe algo: o pierde el reembolso o lo duplica, según el orden. Por qué pasa: dos workflows parecen más complejos que uno, y el problema de la doble escritura no se siente real hasta que una caída lo materializa. Cómo detectarlo: si en tu flujo hay un cambio de estado (un UPDATE, un registro en el ledger) y una llamada a un efecto externo en la misma ejecución, tienes el problema de la doble escritura latente. Cómo corregirlo: separa —decide y anota la comanda de forma atómica en un flujo; ejecuta el efecto en el relay—; la aparente simplicidad de un solo flujo es una deuda que se paga en la primera caída.

Anotar la comanda y cambiar el estado en dos operaciones separadas, sin atomicidad (práctico). Qué pasa: alguien pone un nodo de Postgres que cambia el estado, y otro nodo de Postgres que inserta la comanda, como dos pasos del flujo. Si el flujo se cae entre los dos nodos, queda el estado cambiado sin comanda (efecto perdido) o la comanda sin estado (efecto fantasma). El outbox no sirvió de nada, porque su premisa —la escritura atómica— se rompió. Por qué pasa: en n8n es natural poner una operación por nodo, y "dos escrituras" se traduce por reflejo a "dos nodos". Cómo detectarlo: si el cambio de estado y la inserción de la comanda están en nodos distintos, no son atómicos. Cómo corregirlo: las dos escrituras van en una sola sentencia o transacción —un único nodo de Postgres con una consulta que hace ambas, como el WITH ... INSERT de esta lección, o una sola inserción si el ledger y el outbox son la misma tabla—; la atomicidad de la decisión es la premisa del patrón, no un detalle.

Hacer el efecto del relay sin Idempotency-Key (práctico). Qué pasa: se arma el relay con outbox y todo, pero el HTTP Request a la pasarela no manda una clave de idempotencia. Todo va bien hasta que el relay se cae entre ejecutar y marcar done, reintenta, y emite el reembolso dos veces, porque la pasarela no tenía cómo saber que era el mismo. El outbox garantizó que no se perdiera, pero sin la clave no garantizó que no se duplicara. Por qué pasa: el outbox se siente como "la solución completa", y es fácil olvidar que la mitad de su robustez depende de que la ejecución sea idempotente. Cómo detectarlo: si el efecto del relay puede ejecutarse dos veces (y puede, por diseño) y no lleva clave de idempotencia, va a duplicar. Cómo corregirlo: la clave que guardaste en la comanda (idempotency_key) viaja en cada llamada del efecto —como Idempotency-Key en el header para una API, o como la clave contra el ledger para un efecto interno—; el outbox y la idempotencia son un equipo, ninguno funciona solo.

Dejar comandas atascadas en processing sin recuperación (práctico). Qué pasa: el relay marca una comanda processing, se cae antes de terminar, y esa comanda se queda en processing para siempre —ni pending para reintentarse, ni done—. El efecto quedó a medias y nadie lo retoma. Por qué pasa: el estado processing protege contra que dos relays tomen la misma comanda, pero si el relay muere ahí, nadie la devuelve a pending. Cómo detectarlo: consulta comandas en processing con un attempts alto o un tiempo largo desde que se tomaron; esas están atascadas. Cómo corregirlo: el relay, al tomar comandas, debe recuperar también las que llevan demasiado tiempo en processing (asumiéndolas caídas y reintentándolas) —seguro, porque el efecto es idempotente—; y las que fallan repetidamente van a failed para alerta, tema del Módulo 6. Una comanda nunca debe poder quedarse muda para siempre.

Ejercicios

Ejercicio 1 — Explica por qué no hay orden bueno. Sin el outbox, order-triage tiene que cambiar su estado y llamar a la pasarela. Escribe, para cada uno de los dos órdenes posibles, qué se rompe si el sistema se cae en el punto exacto entre las dos operaciones, y por qué el reintento no lo arregla.

Ver solución

Orden A — estado y luego efecto. Se cambia el estado a "reembolsado", el sistema se cae, y la llamada a la pasarela nunca ocurre. El dinero no se devolvió, pero el estado dice que sí. El reintento no lo arregla porque el reintento consulta el estado, ve "reembolsado", y concluye que no hay nada que hacer: el efecto perdido queda perdido para siempre, y el sistema jura que está completo. Es el fallo silencioso peor, porque nadie se entera hasta que el cliente reclama.

Orden B — efecto y luego estado. Se llama a la pasarela, el dinero se devuelve, el sistema se cae, y el cambio de estado nunca ocurre. Ahora el estado no dice "reembolsado". El reintento consulta el estado, ve que no está hecho, y vuelve a llamar a la pasarela: el reembolso se duplica. El cliente recibe el dinero dos veces. El reintento, que era para arreglar, es justo lo que causa el duplicado.

Por qué no hay orden bueno: en el A, la caída pierde el efecto y el reintento no lo recupera; en el B, la caída deja el efecto sin registrar y el reintento lo duplica. El problema de fondo es que las dos escrituras están en mundos distintos (mi base y la pasarela) y no se pueden hacer atómicas juntas, así que siempre hay un instante entre ambas donde una caída rompe la correctitud. El outbox lo resuelve moviendo el problema: hace atómica la decisión (dos escrituras en mi casa) y separa la ejecución (idempotente, reintentable).

Por qué funciona: articular por qué ninguno de los dos órdenes sirve es lo que hace evidente por qué el outbox no es "una forma más" sino la forma; si no ves que ambos órdenes están rotos, el outbox parece una complicación innecesaria.

Ejercicio 2 — Traza las caídas del relay. El relay ejecuta: (3) llama a la pasarela con Idempotency-Key, (4) marca done. Para cada uno de estos dos momentos de caída, di el estado del mundo y qué pasa al recuperarse, y confirma que el reembolso ocurre exactamente una vez:

(a) El relay se cae justo antes del paso 3 (comanda en processing, efecto sin ejecutar). (b) El relay se cae justo después del paso 3 y antes del 4 (efecto ejecutado, comanda todavía en processing).

Ver solución

(a) Estado: la comanda está en processing, el reembolso no se emitió. Al recuperarse, el relay recupera las comandas atascadas en processing (asumiéndolas caídas), vuelve a ejecutar el paso 3, llama a la pasarela —por primera vez de verdad— con la clave refund:ORD-2041, la pasarela emite el reembolso, y marca done. El reembolso ocurre una vez. Todo bien: como el efecto nunca se había ejecutado, la clave no dedup­lica nada, solo lo ejecuta.

(b) Estado: el reembolso ya se emitió, la comanda sigue en processing. Al recuperarse, el relay recupera la comanda atascada, vuelve a ejecutar el paso 3, y llama a la pasarela otra vez con la misma clave refund:ORD-2041. Aquí actúa la red de seguridad: la pasarela reconoce la clave del reembolso que ya emitió y no lo emite de nuevo —devuelve el resultado del primero—. El relay recibe esa respuesta y marca done. El reembolso ocurre exactamente una vez, aunque el relay ejecutó el efecto dos veces.

En los dos casos, exactamente un reembolso. La diferencia entre (a) y (b) —si el efecto ya se había hecho o no— la resuelve la clave de idempotencia sin que el relay tenga que saber en cuál de los dos casos está. Esa es la belleza: el relay reintenta a ciegas, y la clave se encarga de que no importe.

Por qué funciona: trazar las dos caídas del relay muestra que el "Orden B" (efecto y luego estado), que era el roto al principio, ahora es correcto porque el efecto es idempotente. El outbox no evita que el efecto se ejecute dos veces; hace que ejecutarlo dos veces no cause daño, que es más fácil de garantizar.

Ejercicio 3 — Diseña la comanda para inventario. Cumbre quiere aplicar el outbox también a inventory-sync: cuando order-triage decide descontar el inventario de un pedido de tres líneas, debe anotar las comandas correspondientes de forma atómica con la decisión. Diseña qué comandas se anotan (cuántas y con qué effect_type, aggregate_id, idempotency_key y payload), y explica cómo el relay las ejecuta sin duplicar si se cae a la mitad.

Ver solución

Como el inventario se descuenta por línea (lección 4), se anotan tres comandas, una por sku, todas en la misma transacción atómica que registra la decisión:

outbox:
  { aggregate_id: 'ORD-2041', effect_type: 'inventory_sync',
    idempotency_key: 'inventory:ORD-2041:CF-ARA-500',
    payload: {sku:'CF-ARA-500', quantity:12}, status:'pending' }
  { aggregate_id: 'ORD-2041', effect_type: 'inventory_sync',
    idempotency_key: 'inventory:ORD-2041:TE-CHM-100',
    payload: {sku:'TE-CHM-100', quantity:6}, status:'pending' }
  { aggregate_id: 'ORD-2041', effect_type: 'inventory_sync',
    idempotency_key: 'inventory:ORD-2041:CF-DEC-250',
    payload: {sku:'CF-DEC-250', quantity:4}, status:'pending' }

Puntos clave: la idempotency_key va a la granularidad de la línea (order_id:sku), no del pedido, por la razón de la lección 4 —cada línea es una unidad de trabajo que debe ocurrir una vez—. Las tres comandas se insertan junto con la decisión en una sola transacción, así que o se anotan las tres con la decisión o ninguna: nunca queda un pedido "decidido" con solo dos de sus tres comandas.

Cómo el relay no duplica si se cae a la mitad: supón que ejecuta la comanda 1 (descuenta CF-ARA-500), la 2 (descuenta TE-CHM-100), y se cae antes de la 3. Las comandas 1 y 2 quedaron en done, la 3 en pending (o processing si alcanzó a tomarla). Al recuperarse, el relay toma solo las comandas que no están done —la 3, y la 2 si quedó en processing—. La 2, si la reejecuta, llama al sistema de bodega con la misma clave inventory:ORD-2041:TE-CHM-100, que ya está registrada en el ledger, así que no descuenta de nuevo. La 3 se descuenta por primera vez. Cada sku queda descontado exactamente una vez.

Por qué funciona: aplicaste el outbox a un fan-out (tres comandas) combinando lo de la lección 4 (clave por línea) con lo de esta (atomicidad de la decisión, ejecución idempotente del relay). Es el patrón completo del proyecto de la lección 8, en pequeño: un fan-out de efectos coordinado por un outbox, a prueba de caídas parciales.

Resumen y siguiente paso

En esta lección aprendiste el patrón outbox, la bisagra del módulo. Nace de un problema que ningún reintento arregla solo: el problema de la doble escritura, cambiar tu estado y ejecutar un efecto externo son dos escrituras en mundos distintos que no se pueden hacer atómicas juntas, y cualquiera de los dos órdenes, ante una caída en el momento justo, o pierde el efecto o lo duplica. La solución es separar decidir de ejecutar: el workflow que decide registra su decisión y anota la intención del efecto en una tabla outbox, en una sola operación atómica en su propia base —el mesero clava la comanda en el riel—; un flujo aparte, el relay, lee las comandas pendientes y ejecuta cada efecto de forma idempotente, marcándolas como hechas —la cocina trabaja el riel—. Así el efecto no se pierde (queda durable en el riel) y no se duplica (la clave de idempotencia lo dedup­lica aunque el relay reintente): "al menos una vez" en la entrega más idempotencia en la ejecución da "exactamente una vez" en el mundo real. Y viste que un solo patrón ataca los tres desastres: corta la cascada (el que decide no espera), preserva el orden (el relay procesa por antigüedad), y elimina el duplicado (ejecución idempotente).

Antes de avanzar a la lección 7 deberías poder: explicar el problema de la doble escritura y por qué ningún orden lo resuelve; describir las dos partes del outbox (decisión atómica, ejecución idempotente) y qué garantiza cada una; y trazar qué pasa si el sistema se cae en cada punto de la cadena, confirmando que el efecto ocurre exactamente una vez.

La lección 7 lleva todo esto al terreno de los agentes. Cuando un AI Agent delega trabajo en otro —como viste en la guía de chatbots, con un agente conectado como tool de otro— cada acción que un agente dispara sigue siendo un efecto, y todo lo de este módulo aplica: dos agentes pueden lanzar el mismo efecto sin enterarse, un bucle de delegación puede repetir trabajo, y la solución vuelve a ser la de siempre —efectos idempotentes, el ledger como memoria compartida, y los efectos peligrosos ruteados por un outbox—. Vas a ver cómo poner esos frenos sin romper la autonomía que hace útil a un agente.

Recursos

  • Postgres node — n8n Docs — el nodo con el que escribes la decisión y la comanda de forma atómica (una sentencia o transacción) y con el que el relay toma las comandas con FOR UPDATE SKIP LOCKED.
  • Schedule Trigger — n8n Docs — el disparador del relay, que revisa el outbox cada cierto tiempo y drena las comandas pendientes.
  • HTTP Request node — n8n Docs — el nodo con el que el relay ejecuta el efecto de verdad, enviando la Idempotency-Key que garantiza que la pasarela no lo duplique. Recuerda que el nodo Code no hace HTTP; el efecto va por aquí.
  • Execute Sub-workflow node — n8n Docs — alternativa al HTTP Request cuando el efecto lo ejecuta otro workflow tuyo (issue-refund, inventory-sync) en vez de una API externa; el relay lo llama igual, con la clave de idempotencia en el encargo.
  • SQL INSERT ... ON CONFLICT — PostgreSQL Docs — la escritura condicional que hace idempotente la decisión (ON CONFLICT DO NOTHING), para que dos disparos del que decide no anoten dos comandas.