Módulo 4: El modelo de datos del sistema

8. Proyecto: un ledger de deduplicación para un webhook que dispara doble

Descripción

Al terminar esta lección vas a tener construido, corriendo en tu máquina, el sistema completo que este módulo diseñó pieza por pieza: un run ledger y una tienda de deduplicación en el Postgres local, conectados a un webhook que dispara dos veces, con la prueba a la vista de que el segundo disparo se descarta antes de tocar el efecto. Vas a juntar todo lo anterior —el diseño de las tablas, la compuerta ON CONFLICT, la clave de idempotencia con crypto, la ramificación con IF, el patrón de las dos escrituras del ledger— en un solo flujo que puedes ejecutar, romper a propósito y defender.

Esto importa porque es la diferencia entre entender el patrón y poseerlo. Un proyecto que corre, con sus tablas y su prueba de que no duplica, es lo que llevas a una entrevista, a un portafolio o a tu equipo cuando alguien pregunta "¿y esto cómo evita cobrar dos veces?". No respondes con teoría: abres el flujo, disparas el webhook dos veces, y muestras una sola fila en la tabla y un solo efecto. El entregable de esta lección es exactamente eso: el esquema de las tablas más el flujo que las usa, probado de punta a punta.

Conexión con el módulo: esta lección no introduce conceptos nuevos; integra los siete anteriores. La lección 2 dio el argumento (por qué una base de datos), la 3 el ledger, la 4 el criterio de estrategia, la 5 la compuerta atómica, la 6 el stack local, la 7 la generalidad del patrón. Aquí todo eso se vuelve un sistema que funciona. Y prepara el módulo 5: una vez que un solo workflow es idempotente y deja rastro en un ledger, el siguiente paso es coordinar varios workflows sin que unos dupliquen el trabajo de otros, que es de lo que trata la coordinación de dependencias.

El brief del proyecto

Vamos a ser concretos sobre qué construyes y cómo sabes que quedó bien.

Qué construyes. Un workflow de Cumbre —una versión de order-triage centrada en lo que este módulo enseña— que:

  1. Recibe un pedido por un webhook.
  2. Calcula la idempotency_key del pedido.
  3. Pasa por una compuerta de dedup que decide, atómicamente, si es la primera vez o un duplicado.
  4. En la rama "primera vez": escribe un asiento pending en el ledger, ejecuta el efecto (el cobro, que en pruebas simulamos de forma segura), y actualiza el asiento a done.
  5. En la rama "duplicado": no ejecuta el efecto; opcionalmente deja constancia.

El entregable. Dos cosas: (a) el esquema de las tablas run_ledger y processed_orders —el SQL que las crea—, y (b) el flujo que las usa, capaz de demostrar que un webhook disparado dos veces produce una sola fila en processed_orders, un solo asiento done en run_ledger, y un solo efecto.

Cómo sabes que quedó bien. El criterio de éxito es una prueba, no una opinión: disparas el webhook dos veces con el mismo pedido y verificas, con consultas a las tablas, que el sistema actuó una sola vez. Si la segunda ejecución aparece en verde en el historial de n8n pero no creó un segundo efecto, ganaste. Ese "verde sin duplicar" es la meta de la idempotencia: repetir sin hacer daño.

Antes de empezar, confirma que tienes lo de la lección 6: el Starter Kit corriendo (entras a http://localhost:5678) y una credencial de Postgres que conecta (prueba en verde, Host postgres). Si eso está, seguimos.

Una nota sobre el ritmo: este proyecto se construye por capas, y conviene probar cada capa antes de montar la siguiente. Primero las tablas. Luego el webhook que recibe. Luego la clave. Luego la compuerta. Después la ramificación, y al final el ledger alrededor del efecto. Si armas todo de golpe y algo falla, no sabrás en qué capa; si lo armas por partes, cada checkpoint te dice exactamente hasta dónde llegaste bien. Vamos así, paso a paso.

Paso 1: crear las tablas

Empezamos por el estado, porque el flujo se apoya en él. Un nodo Postgres en operación Execute Query, que corres una sola vez:

-- El run ledger: la historia rica de cada ejecución
CREATE TABLE IF NOT EXISTS run_ledger (
  id               BIGSERIAL     PRIMARY KEY,
  idempotency_key  TEXT          NOT NULL UNIQUE,
  order_id         TEXT          NOT NULL,
  status           TEXT          NOT NULL DEFAULT 'pending',
  result           JSONB,
  created_at       TIMESTAMPTZ   NOT NULL DEFAULT now(),
  updated_at       TIMESTAMPTZ   NOT NULL DEFAULT now()
);

-- La tienda de dedup: el sí/no rápido y atómico
CREATE TABLE IF NOT EXISTS processed_orders (
  idempotency_key  TEXT          PRIMARY KEY,
  order_id         TEXT          NOT NULL,
  processed_at     TIMESTAMPTZ   NOT NULL DEFAULT now()
);

Qué esperar. El nodo corre sin error. Para confirmarlo, un SELECT count(*) FROM run_ledger; y un SELECT count(*) FROM processed_orders; deben devolver 0 cada uno: las tablas existen y están vacías. Ese es el punto de partida limpio. Recuerda que el IF NOT EXISTS hace este paso repetible: si lo corres de nuevo, no rompe nada.

Paso 2: el webhook y cómo se dispara dos veces

El disparador del flujo es un nodo Webhook. Cuando lo configuras, n8n te da una URL. Un pedido de Cumbre llega como un POST a esa URL con un cuerpo JSON, por ejemplo:

{
  "order_id": "ORD-2041",
  "customer_name": "Luna Coffee",
  "order_total": 1734,
  "currency": "MXN"
}

Para simular el doble disparo —el corazón de la prueba—, envías ese mismo POST a la URL del webhook dos veces. Puedes hacerlo con la herramienta que prefieras: la función de "test" del propio n8n repetida, un cliente de API, o cualquier medio que mande la misma petición dos veces. Lo importante es que las dos llevan el mismo order_id y el mismo contenido, porque representan el mismo pedido disparado por accidente dos veces, que es el escenario del módulo.

Un detalle de la lección 2 que ahora es práctico: recuerda que hay diferencia entre probar desde el editor y ejecutar el workflow activo por su webhook. Para que la prueba sea fiel a producción —y para que cualquier estado interno se comporte como en la realidad— conviene activar el workflow y dispararlo por su URL de producción, no solo probarlo desde el editor. Tu estado real, de todos modos, vive en Postgres, que persiste en los dos modos; pero la prueba honesta del doble disparo es con el workflow activo.

Paso 3: calcular la idempotency_key

El primer nodo después del webhook es un Code que calcula la clave con crypto. Recuerda: el nodo Code no hace HTTP ni toca la base de datos, pero sí puede hashear.

// ============================================================
// Nodo: Code — "Compute idempotency key"
// Modo: Run Once for Each Item
//
// ENTRADA:  el pedido que llegó por el webhook
// SALIDA:   el mismo pedido, con idempotency_key
// NOTA:     la clave identifica EL TRABAJO (este pedido con este contenido),
//           no el intento. Dos disparos del mismo pedido → misma clave.
// ============================================================

const crypto = require('crypto');

const order = $input.item.json;

// Clave sintética: order_id + total. Dos disparos idénticos comparten clave
// y el segundo chocará con la restricción de unicidad. Un pedido distinto
// (otro order_id) genera otra clave y se procesa normalmente.
const raw = `${order.order_id}:${order.order_total}`;
const idempotencyKey = crypto.createHash('sha256').update(raw).digest('hex');

return {
  json: {
    ...order,
    idempotency_key: idempotencyKey,
  },
};

Qué esperar. En la salida del nodo, cada item conserva el pedido y suma un campo idempotency_key con una cadena larga de caracteres —el hash—. Lo crucial: si disparas dos veces el mismo ORD-2041 con el mismo total, las dos ejecuciones producen exactamente la misma idempotency_key. Compruébalo mirando la salida de las dos ejecuciones en el historial: la clave coincide. Si no coincidiera, tu deduplicación no tendría contra qué chocar.

Paso 4: la compuerta de deduplicación

El siguiente nodo es el corazón del sistema: un Postgres en operación Execute Query que hace el INSERT ... ON CONFLICT DO NOTHING RETURNING.

# Nodo: Postgres — "Dedup gate" (operación: Execute Query)

Query:
  INSERT INTO processed_orders (idempotency_key, order_id)
  VALUES ($1, $2)
  ON CONFLICT (idempotency_key) DO NOTHING
  RETURNING idempotency_key;

Query Parameters (valores para $1, $2, en orden):
  {{ [ $json.idempotency_key, $json.order_id ] }}

Recuerda por qué se ve así: el texto del SQL lleva los marcadores $1 y $2, y los valores viajan por el campo de parámetros, separados del texto, para que n8n los sanitice y no haya inyección de SQL. La sentencia intenta insertar la clave; si ya existe, DO NOTHING evita el error y RETURNING no devuelve nada.

Qué esperar. En el primer disparo de ORD-2041, la clave no existía, así que se inserta y el nodo devuelve una fila con idempotency_key. En el segundo disparo, la clave ya está, ON CONFLICT DO NOTHING no inserta, y el nodo devuelve vacío (o cero items, según tu versión). Esa diferencia —fila vs vacío— es lo que la siguiente ramificación usa para decidir.

Paso 5: ramificar entre primera vez y duplicado

Un nodo IF mira si la compuerta devolvió la clave:

# Nodo: IF — "¿Es primera vez?"
Condición:  {{ $json.idempotency_key }}  ->  existe / no está vacío
  - true  ->  primera vez  (la compuerta devolvió la clave → sigue al efecto)
  - false ->  duplicado    (la compuerta devolvió vacío → no hagas el efecto)

Como advertimos en la lección 5, confirma en el panel de tu versión qué produce el nodo Postgres cuando RETURNING no trae filas —cero items o un item vacío— y ajusta la condición del IF a eso. La lógica no cambia: actúa solo si la compuerta te devolvió la clave.

Qué esperar. El primer disparo toma la rama true; el segundo, la rama false. Puedes verlo en el historial: en la primera ejecución el flujo continúa hacia el efecto; en la segunda, se va por la rama de duplicado y se detiene sin cobrar.

Paso 6: la rama "primera vez" — ledger y efecto

En la rama true va el trabajo real, con el patrón de las dos escrituras del ledger alrededor del efecto.

IF (true) 
  └─► Postgres: "Ledger — insert pending"    ← Escritura 1 (antes del efecto)
        └─► [Efecto]  Create charge in CRM     ← el cobro (simulado en pruebas)
              └─► Postgres: "Ledger — mark done"  ← Escritura 2 (después del efecto)

La Escritura 1 es un nodo Postgres, operación Execute Query:

Query:
  INSERT INTO run_ledger (idempotency_key, order_id, status)
  VALUES ($1, $2, 'pending');

Query Parameters:
  {{ [ $json.idempotency_key, $json.order_id ] }}

El efecto. En order-triage real, aquí va el HTTP Request que crea el cobro en el CRM. Para probar sin cobrarle a nadie ni gastar, simula el efecto de forma segura: puedes apuntar el HTTP Request a un endpoint de prueba inofensivo que solo hace eco de lo que recibe, o reemplazarlo temporalmente por un nodo Edit Fields (Set) que escriba algo como { "charge_created": true, "charge_id": "CHG-TEST-001" }. Lo importante para el proyecto es que el efecto ocurra una sola vez; qué tan real sea el cobro es secundario mientras aprendes. Deja un comentario claro de que en producción ese nodo es la llamada real al CRM.

La Escritura 2 actualiza el asiento a done con el resultado:

Query:
  UPDATE run_ledger
  SET status = 'done',
      result = $2::jsonb,
      updated_at = now()
  WHERE idempotency_key = $1;

Query Parameters:
  {{ [ $json.idempotency_key, JSON.stringify({ charge_id: $json.charge_id }) ] }}

Fíjate en el $2::jsonb: le decimos a Postgres que ese parámetro es un JSON, para que entre en la columna result de tipo JSONB. El contenido lo armamos con JSON.stringify a partir de lo que el efecto devolvió. Si el formato exacto de parámetros de tu versión difiere, verifícalo en el panel; la idea —actualizar el mismo asiento a done con el resultado— es la que manda.

Qué esperar en la rama primera vez. Después de correr esta rama para ORD-2041, la tabla run_ledger tiene un asiento que pasó de pending a done, con result conteniendo el charge_id y updated_at posterior a created_at. El efecto se ejecutó una vez. Y en processed_orders hay una fila con la clave. Todo el estado del sistema, coherente.

Paso 7: la rama "duplicado" y la prueba final

En la rama false casi no hay nada que hacer —ese es el punto—: no se ejecuta el efecto. Opcionalmente, puedes dejar constancia de que llegó un duplicado, por ejemplo respondiendo al webhook con "ya procesado" o registrando el evento; pero lo esencial es que el efecto no corre.

Ahora, la prueba que corona el proyecto. Con el workflow activo, dispara el webhook dos veces con el mismo ORD-2041. Luego corre estas consultas de verificación (nodos Postgres en Select o Execute Query):

-- ¿Cuántas veces se registró este pedido en la tienda de dedup?
SELECT count(*) AS veces FROM processed_orders WHERE order_id = 'ORD-2041';

-- ¿Cuántos asientos 'done' hay para este pedido en el ledger?
SELECT count(*) AS cobros FROM run_ledger
WHERE order_id = 'ORD-2041' AND status = 'done';

Qué esperar —y esta es la meta de todo el módulo—. La primera consulta devuelve 1: una sola clave en la tienda de dedup, aunque dispararas dos veces. La segunda devuelve 1: un solo cobro. Y si miras el historial de n8n, ves dos ejecuciones en verde: las dos corrieron sin error, pero solo una llegó al efecto. La segunda tomó la rama de duplicado y se detuvo antes de cobrar.

Eso es idempotencia demostrada: no que el sistema "no se disparó dos veces" —sí se disparó dos veces—, sino que disparándose dos veces, actuó una sola. El duplicado no se evitó; se absorbió. Es exactamente la promesa con la que abrió el módulo, ahora convertida en algo que puedes ejecutar y mostrar.

Qué responderle al que disparó: la respuesta también es idempotente

Hay un detalle que separa un proyecto de aprendizaje de uno listo para el mundo real, y conviene incluirlo porque cierra el círculo. La pregunta es: cuando llega el segundo disparo y lo descartas, ¿qué le respondes a quien lo mandó?

Piénsalo desde el otro lado. El sistema que llama a tu webhook disparó dos veces justamente porque, muchas veces, no recibió una respuesta clara la primera vez —por eso reintentó—. Si a su segundo intento le respondes con un error, o no le respondes, va a pensar que algo sigue mal y quizás reintente una tercera vez. La respuesta correcta a un duplicado no es un error: es la misma respuesta de éxito que habría recibido la primera vez, para que el que llama entienda "listo, esto ya está procesado" y deje de reintentar.

Por eso, en la rama false (duplicado), lo ideal no es simplemente detenerse en silencio, sino responder al webhook con éxito, idealmente con el mismo resultado que produjo la primera vez. Y ese resultado lo tienes: está guardado en el run_ledger, en la columna result del asiento done original. La rama de duplicado puede consultarlo y devolverlo:

IF (false, duplicado)
  └─► Postgres: buscar el resultado original
        SELECT result FROM run_ledger
        WHERE idempotency_key = $1 AND status = 'done';
  └─► Respond to Webhook:  200 OK, con el result recuperado
        (el que llama recibe "ya procesado, aquí está tu resultado" y deja de reintentar)

Fíjate en lo elegante que resulta: el ledger que construiste para auditar y recuperar te sirve también para responder idempotentemente. La primera vez guardaste el resultado; en el duplicado, lo devuelves en vez de recalcularlo. El que llama recibe la misma respuesta las dos veces, que es la definición más pura de idempotencia desde afuera: hacer la misma petición dos veces produce la misma respuesta y un solo efecto. Para el proyecto de aprendizaje esto es opcional, pero saber que existe es lo que convierte "no dupliqué el cobro" en "construí una API idempotente de verdad".

El flujo completo, de un vistazo

Webhook: "Cumbre order in"
  └─► Code: "Compute idempotency key"                (crypto → idempotency_key)
        └─► Postgres: "Dedup gate"                   (INSERT ... ON CONFLICT ... RETURNING)
              └─► IF: "¿Es primera vez?"
                    │
                    ├── true (devolvió la clave) ─────────────────────────────┐
                    │     └─► Postgres: "Ledger — insert pending"  (status='pending')
                    │           └─► HTTP Request: "Create charge"  (el efecto, 1 sola vez)
                    │                 └─► Postgres: "Ledger — mark done"  (status='done', result)
                    │
                    └── false (devolvió vacío) ───────────────────────────────┐
                          └─► (opcional) responder "ya procesado" / registrar duplicado
                                (SIN efecto)

Ese diagrama es la mitad del entregable; el CREATE TABLE de las dos tablas es la otra mitad. Juntos son un sistema idempotente completo y defendible.

Definición de terminado

Antes de dar el proyecto por cerrado, revisa esta lista. No es burocracia: cada punto es una de las decisiones que el módulo defendió, y si alguno falla, el sistema duplica en algún escenario.

  • Las tablas run_ledger y processed_orders existen en el Postgres local y arrancan vacías (SELECT count(*) da 0).
  • La idempotency_key es la misma en los dos disparos del mismo pedido (no incluye tiempo ni número de intento).
  • La compuerta usa INSERT ... ON CONFLICT DO NOTHING RETURNING y pasa los valores por parámetros ($1, $2), no concatenados.
  • El efecto está después de la compuerta y solo en la rama de primera vez.
  • El ledger escribe pending antes del efecto y done después.
  • La prueba pasa: dos disparos del mismo pedido → una fila en processed_orders, un asiento done, un solo efecto, dos ejecuciones en verde.
  • (Opcional, nivel producción) La rama de duplicado responde al webhook con éxito y el resultado original recuperado del ledger.

Si los seis primeros están marcados, tienes un sistema idempotente correcto. El séptimo es la milla extra que lo lleva a producción.

Errores comunes

Probar el doble disparo desde el editor y confundirte con el resultado (práctico). Qué pasa: se prueba el flujo desde el editor en vez de por el webhook activo, y algo se comporta distinto de lo esperado. Por qué pasa: el editor y el webhook activo no son idénticos; para una prueba fiel del doble disparo conviene el workflow activo disparado por su URL. Cómo detectarlo: si tus dos "disparos" fueron dos clics de "Test workflow", no es la prueba real. Cómo corregirlo: activa el workflow y dispáralo dos veces por su URL de producción. Tu estado en Postgres persiste igual, pero la prueba honesta del escenario es con el workflow activo.

Poner el efecto antes de la compuerta (conceptual, el que arruina todo). Qué pasa: por descuido, el HTTP Request del cobro queda antes del nodo de dedup, así que cobra siempre y la compuerta solo decide después. Por qué pasa: se arma el flujo en desorden. Cómo detectarlo: si el efecto corre en las dos ejecuciones, míralo: seguro está antes de la compuerta o en la rama equivocada. Cómo corregirlo: la compuerta va antes del efecto, y el efecto va solo en la rama true. Nada debe poder cobrar sin haber pasado primero por la compuerta y haber caído en "primera vez".

Olvidar activar el RETURNING o mal armar el IF (práctico). Qué pasa: la compuerta no devuelve nada útil, o el IF evalúa mal, y las dos ejecuciones toman la misma rama. Por qué pasa: falta el RETURNING, o la condición del IF no coincide con lo que el nodo Postgres produce en el caso duplicado. Cómo detectarlo: si las dos ejecuciones cobran, o ninguna lo hace, revisa la compuerta y la condición del IF. Cómo corregirlo: asegúrate de que la query lleve RETURNING idempotency_key, y prueba en el panel qué devuelve el nodo en el caso duplicado (cero items o item vacío) para ajustar la condición.

Meter en la clave algo que cambia entre disparos (conceptual). Qué pasa: la idempotency_key incluye un timestamp o un id de ejecución, así que los dos disparos generan claves distintas, ninguno choca, y se cobra dos veces. Por qué pasa: se confunde identificar el trabajo con identificar el intento. Cómo detectarlo: si las dos ejecuciones producen idempotency_key distintas para el mismo pedido, la clave está mal. Cómo corregirlo: la clave debe depender solo de lo que define el pedido (su order_id y contenido), nunca del momento ni del número de intento.

Escribir el ledger solo al final (conceptual). Qué pasa: se hace una sola escritura al ledger, en done, después del efecto, saltándose el pending. Por qué pasa: parece más simple. Cómo detectarlo: si tu run_ledger nunca tiene filas en pending, es esto. Cómo corregirlo: escribe pending antes del efecto y actualiza a done después. Si el sistema se cae entre el efecto y la escritura final, el pending previo es la única pista de que algo quedó a medias; sin él, tendrías un cobro sin rastro.

Ejercicios

Ejercicio 1 — Rómpelo a propósito. Con tu proyecto funcionando, haz estos tres cambios de uno en uno, predice qué pasará, ejecútalo y compara. (a) Mueve el efecto para que quede antes de la compuerta. (b) Cambia la idempotency_key para que incluya Date.now(). (c) Quita el RETURNING de la compuerta.

Ver solución

(a) Con el efecto antes de la compuerta: cobra en los dos disparos. El efecto corre siempre porque ya no depende de la decisión de dedup; la compuerta, puesta después, solo registra, pero el daño ya está hecho. Confirma que la ubicación del efecto —después de la compuerta y en la rama true— es lo que da la idempotencia, no la compuerta por sí sola.

(b) Con Date.now() en la clave: cobra en los dos disparos. Cada disparo ocurre en un instante distinto, así que las claves difieren, ninguna choca con la restricción de unicidad, y las dos pasan como "primera vez". Demuestra que la clave debe identificar el trabajo, no el intento: cualquier ingrediente que cambie entre disparos rompe la deduplicación.

(c) Sin RETURNING: dependiendo de cómo tengas el IF, probablemente las dos ejecuciones toman la misma rama (porque el nodo ya no devuelve la señal que distingue insertar de chocar). El INSERT no falla —ON CONFLICT DO NOTHING lo evita— pero el flujo pierde la información de qué pasó. Confirma que RETURNING es lo que convierte "insertó o chocó" en algo que puedes ramificar.

Por qué funciona: romper a propósito es la mejor forma de entender por qué cada pieza está donde está. Los tres experimentos aíslan las tres condiciones de la idempotencia: el efecto va después de la decisión, la clave identifica el trabajo, y la compuerta informa qué pasó. Quita cualquiera y el sistema duplica.

Ejercicio 2 — Aísla el estado colgado. Simula una caída: en la rama true, después de la Escritura 1 (pending) y del efecto, desactiva temporalmente la Escritura 2 (done), y dispara un pedido. Después, escribe la consulta que encuentra los asientos que quedaron colgados en pending por más de, digamos, cinco minutos, y explica para qué serviría en un sistema real.

Ver solución

Al desactivar la Escritura 2, el asiento queda en pending: el efecto ocurrió pero nunca se marcó como done, simulando una caída entre el efecto y el cierre. La consulta:

SELECT order_id, idempotency_key, created_at
FROM run_ledger
WHERE status = 'pending'
  AND created_at < now() - INTERVAL '5 minutes';

Para qué sirve: en un sistema real, un asiento colgado en pending es una alerta —"aquí empezó un trabajo que no sé cómo terminó"—. Un workflow que corre esta consulta periódicamente puede detectar ejecuciones que se cayeron a media y disparar una revisión o una recuperación. Es justo la materia prima del módulo 6. Y es la razón por la que pending es un estado propio: distingue "esto quedó a medias" de "esto nunca empezó", y esa distinción es la base de recuperarse de una caída.

Por qué funciona: el ejercicio te hace ver el valor del patrón de las dos escrituras. En un sistema sin pending, esa caída sería invisible: un efecto sin rastro. Con pending, la caída deja una huella consultable. Diseñaste el ledger para que los fallos sean visibles, y aquí lo compruebas.

Ejercicio 3 — Defiéndelo. Escribe el guion de dos minutos con el que presentarías este proyecto en una entrevista, respondiendo a "¿cómo garantizas que un webhook que dispara dos veces no cobra dos veces?". Debe cubrir: el problema, dónde vive la verdad, el mecanismo atómico y la prueba.

Ver solución

Un guion de referencia:

El problema es que un webhook puede dispararse dos veces por el mismo pedido —un reintento, un doble clic— y cada disparo es una ejecución nueva que no recuerda a la anterior. Si el efecto es crear un cobro, eso significa cobrar dos veces.

La verdad de "este pedido ya se procesó" no puede vivir en la memoria del workflow, porque se borra al terminar cada ejecución, ni en Static Data, que no persiste al probar y no es atómico. Vive en una tabla de Postgres, processed_orders, con la idempotency_key como clave única.

El mecanismo es una sola sentencia atómica: INSERT ... ON CONFLICT DO NOTHING RETURNING. Antes de cobrar, intento insertar la clave del pedido. Si la inserto, es la primera vez y cobro; si choca con la restricción de unicidad, es un duplicado y no hago nada. Como es una sola operación indivisible, no hay una condición de carrera entre "verificar" y "registrar": aunque los dos disparos lleguen a la vez, la base serializa, uno gana el insert y el otro choca. Además llevo un run ledger que registra cada ejecución en pending antes del efecto y done después, para poder auditar y recuperar.

Y lo puedo probar: disparo el webhook dos veces con el mismo pedido, y les muestro que hay una sola fila en la tienda de dedup, un solo cobro, y las dos ejecuciones en verde. El duplicado no se evitó; se absorbió.

Por qué funciona: la respuesta recorre las cuatro capas —problema, dónde vive la verdad, mecanismo, prueba— sin perderse en detalles, y termina en la demostración. Es la diferencia entre "sé de idempotencia" y "construí un sistema idempotente y aquí está corriendo". Lo segundo es lo que se contrata.

Resumen y siguiente paso

Cerraste el módulo construyendo el sistema completo. Tienes, corriendo en tu máquina y a costo cero, un run ledger y una tienda de deduplicación en el Postgres local, conectados a un webhook que dispara dos veces, con la prueba a la vista: una sola clave en processed_orders, un solo asiento done en run_ledger, un solo efecto, y las dos ejecuciones en verde. El entregable —el esquema de las tablas más el flujo que las usa— es defendible en una entrevista o un portafolio.

Y lo más importante: cada pieza está donde está por una razón que ahora entiendes. La compuerta va antes del efecto y en la rama de primera vez. La clave identifica el trabajo, no el intento. El ledger escribe pending antes y done después, para que las caídas dejen rastro. La verdad vive en una base de datos, no en la memoria del workflow. Rompiste el sistema a propósito para ver qué sostiene cada parte, y aislaste un estado colgado para ver el valor de pending.

Antes de cerrar deberías poder: construir el flujo completo de memoria en sus siete pasos; explicar cada decisión de diseño; y demostrar la idempotencia con dos disparos y dos consultas.

Con esto termina el modelo de datos del sistema. Lo que sigue, en el módulo 5, es coordinar varios workflows que dependen entre sí sin que unos dupliquen el trabajo de otros: orquestación y coreografía, el grafo de dependencias, fan-out y fan-in sin perder items, y el patrón outbox —decidir y ejecutar por separado—, que se apoya directamente en el ledger que acabas de construir. Un solo workflow idempotente es la base; un sistema de workflows coordinados y confiables es la meta. Ya tienes la base.

Recursos