Módulo 4: El modelo de datos del sistema
5. Una tienda de dedup entre ejecuciones
Descripción
Al terminar esta lección vas a poder construir una tienda de deduplicación en Postgres que decide, en un solo paso atómico e indivisible, si un pedido es nuevo o repetido —y lo hace de forma segura incluso cuando dos disparos del mismo pedido llegan casi al mismo tiempo—. Vas a entender qué es una restricción de unicidad, qué hace exactamente INSERT ... ON CONFLICT DO NOTHING, y cómo la cláusula RETURNING te permite saber si acabas de insertar (primera vez) o si chocaste (duplicado). Vas a ver cómo el nodo Postgres pasa parámetros de forma segura para no abrir la puerta a la inyección de SQL, cómo calcular la idempotency_key con crypto en un nodo Code, y cómo ramificar el workflow para actuar solo cuando corresponde.
Esto importa porque aquí se cierra, por fin, la trampa de "verificar y luego actuar" que el módulo 2 dejó marcada y que ninguna de las alternativas —ni Static Data, ni la ventana de tiempo, ni el nodo Remove Duplicates— podía cerrar del todo contra el doble disparo casi simultáneo. La solución no es escribir más código; es aprovechar una garantía que la base de datos ya te ofrece. Es la lección más técnica del módulo y la que hace que todo lo anterior sirva de algo: sin este paso atómico, tendrías un ledger que registra duplicados pero un sistema que los sigue creando.
Conexión con el módulo: la lección 3 construyó el ledger que registra; esta construye la tienda que decide. La lección 4 te dio el criterio para saber por qué eliges la clave-ya-vista en una tabla propia; esta la implementa. La idempotency_key viene del módulo 2 y del diseño de la lección 3. Y el resultado —una bifurcación limpia entre "primera vez" y "duplicado"— es lo que el proyecto de la lección 8 conecta a un webhook que dispara doble para probar, de punta a punta, que el segundo disparo no crea un segundo cobro.
La trampa de los dos pasos, y por qué la puerta la cierra la base de datos
Volvamos a la trampa, porque entenderla es entender por qué la solución es tan elegante.
Deduplicar parece dos acciones: primero verificas si ya viste la clave, y si no, actúas (y la registras). El problema es el hueco entre esas dos acciones. Imagina dos porteros trabajando la misma puerta con la misma lista, y dos personas idénticas llegando al mismo instante. El portero A mira la lista: "no está". El portero B, en el mismo segundo, mira la lista: "no está" también, porque A todavía no la anotó. Los dos concluyen "es nueva", los dos la dejan pasar, los dos la anotan. Resultado: entró dos veces, y la lista ni siquiera se ve mal —tiene una sola entrada—.
Eso es exactamente lo que pasa cuando el webhook de ORD-2041 dispara dos veces casi juntas. Cada disparo es una ejecución que "verifica" y "actúa". Si las dos verifican antes de que cualquiera registre, las dos ven "no está" y las dos crean el cobro. El hueco entre verificar y actuar es una condición de carrera, y no la cierras verificando "con más cuidado": por más rápido que verifiques, siempre existe un instante entre la verificación y el registro donde el otro disparo se puede colar.
La única forma de cerrar el hueco es eliminarlo: hacer que "verificar si existe" y "registrar que ahora existe" sean una sola operación indivisible, imposible de interrumpir a la mitad. Y eso es precisamente lo que una base de datos relacional sabe hacer y un truco dentro del workflow no. Volviendo a los porteros: en vez de "mira la lista y luego anota", la base ofrece un torniquete con lector: metes la tarjeta y el torniquete, en un solo movimiento atómico, o te deja pasar y marca tu entrada, o se traba porque tu marca ya estaba. No hay un instante entre "verificar" y "marcar" donde otro se cuele, porque son el mismo gesto.
Ese torniquete tiene dos piezas en Postgres: una restricción de unicidad en la columna de la clave, y la cláusula ON CONFLICT DO NOTHING. Vamos a construirlas.
La restricción de unicidad: la base garantiza que no hay repetidos
Primero, la tabla. La tienda de dedup es deliberadamente mínima —recuerda de la lección 4 que su trabajo es un sí/no rápido, no guardar la historia rica del ledger—:
CREATE TABLE IF NOT EXISTS processed_orders (
idempotency_key TEXT PRIMARY KEY,
order_id TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Lo esencial está en la primera columna: idempotency_key TEXT PRIMARY KEY. Al declararla PRIMARY KEY, le estás diciendo a Postgres dos cosas a la vez: que esta columna identifica cada fila, y —lo que nos importa— que no puede haber dos filas con el mismo valor, jamás. Una clave primaria es única por definición. (Podrías usar también UNIQUE en una columna aparte; para esta tabla, donde la clave es la identidad, PRIMARY KEY es lo natural.)
¿Qué es una restricción de unicidad? Es una regla que la base de datos hace cumplir por ti en cada escritura. No es algo que tú programes y esperes recordar aplicar en cada workflow: es una propiedad de la tabla. A partir de que la declaras, Postgres rechaza físicamente cualquier intento de insertar una segunda fila con una idempotency_key que ya existe. No importa quién lo intente, desde qué workflow, ni cuántos disparos lleguen a la vez: la base es el árbitro, y su regla es inviolable.
Piénsalo como la cerradura de una taquilla numerada. La taquilla 42 admite un candado y solo uno. Si tú ya pusiste el tuyo, el siguiente que intente colgar un candado en la 42 se encuentra con que no cabe: la cerradura ya está ocupada. No hay que vigilar ni preguntar; la taquilla misma impone que solo haya un candado. La restricción de unicidad es esa cerradura, aplicada a los valores de una columna.
Esta garantía es la mitad de la solución. La otra mitad es qué hacer cuando la cerradura ya está ocupada.
ON CONFLICT DO NOTHING: insertar, o no hacer nada, sin fallar
Si intentas insertar una clave repetida en una columna única, por defecto Postgres lanza un error —"clave duplicada"— y detiene la operación. Eso, en un workflow, significa que el nodo Postgres se pone rojo y la ejecución se corta. Podrías capturar ese error y decidir qué hacer, pero hay una forma mucho más limpia, hecha justo para esto.
INSERT INTO processed_orders (idempotency_key, order_id)
VALUES ($1, $2)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING idempotency_key;
Vamos a desarmarla, porque cada parte hace un trabajo preciso:
INSERT INTO processed_orders (idempotency_key, order_id) VALUES ($1, $2)— intenta insertar una fila con la clave y el pedido. Los$1y$2son marcadores de parámetro, no los valores en sí; enseguida vemos por qué importan.ON CONFLICT (idempotency_key)— "si esta inserción choca con la restricción de unicidad de la columnaidempotency_key…". Es la rama que se activa cuando la cerradura ya estaba ocupada.DO NOTHING— "…entonces no hagas nada, y sobre todo, no lances un error". El nodo no se pone rojo. La operación termina limpiamente sin haber insertado.RETURNING idempotency_key— "y devuélveme la clave de la fila que insertaste". Esta línea es la que te deja saber qué pasó.
Y aquí está el truco que hace todo funcionar. Esa sentencia tiene dos desenlaces posibles, y RETURNING te dice cuál ocurrió:
- Si la clave era nueva: Postgres inserta la fila y
RETURNINGte devuelve una fila con laidempotency_key. Traducción: "era la primera vez; acabas de registrarla; actúa". - Si la clave ya existía:
ON CONFLICT DO NOTHINGno inserta nada, así que no hay fila que devolver yRETURNINGno devuelve nada (cero filas). Traducción: "era un duplicado; ya estaba registrada; no actúes".
Detente en lo elegante que es esto. En una sola sentencia, indivisible, hiciste tres cosas: verificaste si la clave existía, la registraste si no existía, y te enteraste de cuál de los dos casos fue. No hay un hueco entre "verificar" y "registrar" donde el otro disparo se cuele, porque no son dos pasos: son uno. La condición de carrera desaparece, no porque la manejes con cuidado, sino porque la operación es atómica por construcción. Es el torniquete con lector: metes la clave, y en un solo movimiento pasa y se marca, o se traba.
Contra el doble disparo, esto es lo que pasa:
Disparo #1 → INSERT ... ON CONFLICT DO NOTHING RETURNING → devuelve fila → ACTÚA (crea el cobro)
Disparo #2 → INSERT ... ON CONFLICT DO NOTHING RETURNING → devuelve VACÍO → DESCARTA
Aunque los dos disparos lleguen en el mismo segundo, la base de datos los serializa: uno gana el INSERT y recibe la fila de vuelta; el otro choca con la restricción y recibe vacío. No hay forma de que los dos ganen, porque la cerradura solo admite un candado. Es imposible que se cobre dos veces.
Los parámetros: $1, $2 y por qué nunca concatenas valores en el SQL
Fíjate otra vez en VALUES ($1, $2). Esos marcadores merecen una explicación, porque involucran una de las reglas de seguridad más importantes de cualquier sistema que hable con una base de datos.
La tentación del principiante es armar la consulta pegando los valores directamente en el texto del SQL, algo como VALUES (' + orderId + '). Nunca hagas eso. Si un valor contiene comillas o fragmentos de SQL —por accidente o porque alguien lo puso ahí a propósito—, ese texto se mezcla con tu consulta y puede cambiar lo que la consulta hace. Es la inyección de SQL, una de las vulnerabilidades más viejas y más costosas que existen. Pega un valor malicioso en un WHERE y podrías estar borrando una tabla sin saberlo.
La solución es separar el texto de la consulta de los valores. En vez de pegar el valor, dejas un marcador —$1, $2, $3…— y entregas los valores por un canal aparte. La base de datos entonces trata esos valores como datos puros, nunca como parte del SQL: no importa qué contengan, no pueden alterar la estructura de la consulta. Según la documentación del nodo Postgres, "n8n sanitiza los datos en los parámetros de consulta, lo que previene la inyección de SQL".
En el nodo Postgres, con la operación Execute Query, esto se ve así:
# 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 (los valores que sustituyen a $1, $2, en orden):
{{ [ $json.idempotency_key, $json.order_id ] }}
El texto del SQL va en el campo Query, con los marcadores $1 y $2. Los valores van aparte, en el campo Query Parameters, como una lista en el orden de los marcadores: el primer elemento sustituye a $1, el segundo a $2. La base recibe la estructura por un lado y los datos por otro, y los une de forma segura.
Una nota de honestidad y verificación: los detalles de cómo se llama el campo y qué formato exacto espera pueden variar entre versiones del nodo. La documentación al escribir esta guía indica los marcadores numerados $1, $2, $3 para la operación Execute Query, y un campo de parámetros donde entregas los valores. Si en tu versión el nodo se ve distinto, la idea es la que no cambia: el texto del SQL y los valores viajan por canales separados; tú nunca pegas un valor dentro del texto. Confirma la forma exacta en el panel de tu nodo.
La idempotency_key: calcularla con crypto en un nodo Code
La clave que metes en $1 sale de un nodo Code, igual que en la lección 3. Recuerda las reglas de n8n 2.0: el nodo Code no puede hacer peticiones HTTP ni tocar la base de datos —de eso se encarga el nodo Postgres—, pero sí puede usar crypto para calcular un hash.
// ============================================================
// Nodo: Code — "Compute idempotency key"
// Modo: Run Once for Each Item
//
// ENTRADA: un pedido de Cumbre con order_id
// SALIDA: el mismo item, con idempotency_key calculada
// NOTA: crypto SÍ está permitido en el nodo Code de n8n 2.0.
// No hacemos HTTP ni tocamos Postgres aquí; eso es del nodo Postgres.
// ============================================================
const crypto = require('crypto');
const order = $input.item.json;
// La clave identifica EL TRABAJO, no el intento. Dos disparos del mismo
// pedido con el mismo contenido deben producir la misma clave, para que
// el segundo choque con la restricción de unicidad y se descarte.
const raw = `${order.order_id}:${order.order_total}`;
const idempotencyKey = crypto.createHash('sha256').update(raw).digest('hex');
return {
json: {
...order,
idempotency_key: idempotencyKey,
},
};
Dos decisiones de diseño que vale la pena marcar. Primero, qué metes en el hash define qué consideras "el mismo trabajo" —justo el tema del falso duplicado de la lección 4—. Aquí usamos order_id más el total: dos disparos idénticos del pedido ORD-2041 producen la misma clave y el segundo se descarta; pero si Luna Coffee hace un pedido genuinamente nuevo, su order_id distinto genera una clave distinta y se procesa. Segundo, si tu order_id ya es un identificador único y estable por sí solo, puedes usarlo directo como clave natural, sin hash; el hash es útil cuando quieres que la clave dependa de varios campos o cuando el identificador natural es largo o sensible.
El workflow completo: ramificar entre "primera vez" y "duplicado"
Juntemos las piezas. La tienda de dedup se coloca como una compuerta antes del efecto: nada llega a crear el cobro sin pasar primero por ella.
Webhook
└─► Code: "Compute idempotency key"
└─► Postgres: "Dedup gate" (Execute Query, INSERT ... ON CONFLICT ... RETURNING)
└─► IF: "¿Se insertó?"
├─ true (devolvió fila → primera vez)
│ └─► AI Agent → HTTP Request: "Create charge in CRM"
│
└─ false (devolvió vacío → duplicado)
└─► (no hacer nada / registrar el duplicado / responder "ya procesado")
La compuerta es el nodo Postgres con ON CONFLICT ... RETURNING. Después viene un nodo IF que mira si la compuerta devolvió una fila o no. La rama "primera vez" sigue al efecto; la rama "duplicado" no crea nada.
Aquí hay un detalle práctico sobre el que conviene ser honesto, porque depende del comportamiento del nodo en tu versión. Cuando RETURNING no devuelve filas (el caso duplicado), el nodo Postgres puede comportarse de dos maneras: producir cero items de salida —con lo cual los nodos siguientes simplemente no corren, y el efecto se evita solo— o producir un item vacío. Para no depender de ese detalle, el patrón robusto es añadir el IF que verifica explícitamente si vino una idempotency_key de vuelta:
# Nodo: IF — "¿Se insertó?"
Condición: {{ $json.idempotency_key }} -> existe / no está vacío
- true -> primera vez, sigue al efecto
- false -> duplicado, rama de descarte
Si la compuerta devolvió la clave, es la primera vez y el IF manda a true. Si devolvió vacío, es duplicado y el IF manda a false. Confirma en el panel de tu versión si el nodo produce cero items o un item vacío en el caso duplicado, y ajusta la condición del IF a lo que veas; la lógica —"actúa solo si la compuerta te devolvió la clave"— es la misma en ambos casos.
Ejemplo trabajado: los dos disparos de ORD-2041
Sigamos el pedido ORD-2041 en sus dos disparos, paso a paso.
Primer disparo, 09:12. El webhook recibe ORD-2041. El nodo Code calcula la clave —digamos a1b2c3…—. La compuerta ejecuta el INSERT ... ON CONFLICT DO NOTHING RETURNING. Como la clave a1b2c3… no estaba en processed_orders, se inserta la fila y RETURNING devuelve { idempotency_key: 'a1b2c3…' }. El IF ve la clave, va a true, el AI Agent clasifica y el HTTP Request crea el cobro. Todo correcto.
Segundo disparo, 09:19. El webhook recibe otra vez ORD-2041, en una ejecución nueva que no sabe nada de la anterior. El nodo Code calcula la misma clave a1b2c3… —porque la clave identifica el trabajo, no el intento—. La compuerta ejecuta el mismo INSERT, pero esta vez la clave a1b2c3… ya existe en la tabla. ON CONFLICT DO NOTHING no inserta nada, RETURNING no devuelve nada, y el IF va a false. No se crea un segundo cobro.
Qué esperar. En processed_orders hay una sola fila para a1b2c3…, escrita a las 09:12. En el CRM de Cumbre hay un solo cobro. Y —esto es lo importante— las dos ejecuciones aparecen en verde en el historial de n8n: las dos corrieron bien, ninguna falló. La segunda simplemente tomó la rama de duplicado y no hizo el efecto. El sistema hizo lo correcto sin errores, que es la meta de la idempotencia: repetir sin duplicar.
Y ahora sí, resolviste el caso que ni Static Data ni la ventana de tiempo podían: el segundo disparo llegó siete minutos después, en otra ejecución, y aun así se descartó. No porque una ventana lo atrapara —siete minutos podrían caer fuera de muchas ventanas—, sino porque la clave quedó registrada de forma persistente y la base garantizó, atómicamente, que solo una inserción ganara.
Combinar con el ledger: decidir y registrar
Vale la pena ver cómo esta tienda convive con el ledger de la lección 3, porque en order-triage real usas las dos. La tienda de dedup es la compuerta rápida que decide sí/no; el ledger es el libro que registra la historia. Un orden razonable:
Webhook → Code (clave)
→ Postgres: Dedup gate (ON CONFLICT ... RETURNING) ← decide
├─ duplicado → registra en el ledger "visto de nuevo" (opcional) y termina
└─ primera vez
→ Postgres: Ledger insert 'pending' ← registra intención
→ AI Agent → HTTP Request: crear cobro ← efecto
→ Postgres: Ledger update 'done' ← registra desenlace
La compuerta corta a los duplicados antes de que toquen el efecto o llenen el ledger de ruido. El ledger, del lado de la primera vez, guarda la historia rica que vas a querer cuando alguien pregunte qué pasó. Cada tabla hace lo que hace bien: la tienda decide en un instante, el ledger recuerda con detalle.
Errores comunes
Verificar con un SELECT y luego insertar en dos pasos (conceptual, y es el grande). Qué pasa: alguien hace un nodo Postgres que consulta SELECT ... WHERE idempotency_key = ..., un IF que revisa si vino algo, y solo si no vino, otro nodo que inserta y actúa. Funciona en las pruebas y falla con el doble disparo casi simultáneo. Por qué pasa: son dos pasos con un hueco en medio; dos ejecuciones pueden hacer el SELECT antes de que cualquiera inserte, las dos ver "no está" y las dos actuar. Es la condición de carrera intacta. Cómo detectarlo: si tu deduplicación tiene un SELECT seguido de un INSERT en nodos separados, la tienes. Cómo corregirlo: colapsa los dos pasos en uno con INSERT ... ON CONFLICT DO NOTHING RETURNING. La atomicidad no la da tu cuidado, la da que sea una sola sentencia.
Concatenar valores en el texto del SQL (práctico, y peligroso). Qué pasa: se arma la consulta pegando el order_id directo en el string, y todo "funciona" hasta que un valor con caracteres raros rompe la consulta o —peor— alguien inyecta SQL. Por qué pasa: concatenar es lo primero que a uno se le ocurre. Cómo detectarlo: si en tu campo Query hay comillas rodeando una expresión {{ }} en vez de un $1, estás concatenando. Cómo corregirlo: usa marcadores $1, $2 en el texto y entrega los valores por el campo de parámetros. Nunca pegues un valor dentro del SQL.
Olvidar el RETURNING y no saber qué pasó (práctico). Qué pasa: se usa INSERT ... ON CONFLICT DO NOTHING sin RETURNING, la inserción no falla nunca (bien), pero el workflow no tiene forma de distinguir "inserté" de "choqué", así que actúa siempre. Por qué pasa: ON CONFLICT DO NOTHING por sí solo evita el error pero no te informa del desenlace. Cómo detectarlo: si tu compuerta no tiene RETURNING y aun así ramificas, ¿con base en qué ramificas? Cómo corregirlo: agrega RETURNING idempotency_key para que el caso "primera vez" devuelva una fila y el caso "duplicado" devuelva vacío, y ramifica con eso.
Meter en la clave un campo que cambia entre intentos (conceptual). Qué pasa: la idempotency_key incluye un timestamp del momento del disparo, así que los dos disparos del mismo pedido generan claves distintas y ninguno choca: se cobra dos veces. Por qué pasa: se confunde "identificar el intento" con "identificar el trabajo". Cómo detectarlo: si tu clave cambia cuando reintentas el mismo trabajo, está mal construida. Cómo corregirlo: la clave debe depender solo de lo que define el trabajo (el pedido y su contenido), no del momento ni del número de intento. Es la lección de claves del módulo 2 aplicada aquí.
Asumir el comportamiento del nodo en el caso "cero filas" (práctico). Qué pasa: se conecta el efecto directo después de la compuerta esperando que "cero filas" lo salte, y en cierta versión el nodo emite un item vacío que sí dispara el efecto. Por qué pasa: el comportamiento del nodo ante RETURNING vacío puede variar entre versiones. Cómo detectarlo: prueba el caso duplicado y mira si el efecto corre cuando no debía. Cómo corregirlo: no dependas del comportamiento implícito; pon un IF explícito que compruebe si vino la idempotency_key de vuelta, y deja pasar al efecto solo en la rama true.
Ejercicios
Ejercicio 1 — Traza los dos desenlaces. Para la sentencia INSERT INTO processed_orders (idempotency_key, order_id) VALUES ($1, $2) ON CONFLICT (idempotency_key) DO NOTHING RETURNING idempotency_key;, responde: (a) qué devuelve cuando la clave es nueva, (b) qué devuelve cuando la clave ya existía, y (c) por qué en el segundo caso el nodo no se pone rojo.
Ver solución
(a) Devuelve una fila con la idempotency_key que acaba de insertar. La inserción ocurrió, y RETURNING te entrega la clave de la fila nueva. Es la señal de "primera vez, actúa".
(b) No devuelve nada (cero filas). Como la clave ya existía, ON CONFLICT DO NOTHING decidió no insertar, así que no hay fila nueva que RETURNING pueda devolver. Es la señal de "duplicado, no actúes".
(c) Porque DO NOTHING le dice a Postgres que ante el conflicto no lance un error, solo que no haga nada. Sin esa cláusula, el intento de insertar una clave repetida en una columna única sí lanzaría un error de "clave duplicada" y detendría el nodo. ON CONFLICT DO NOTHING convierte ese error en un no-op silencioso, y RETURNING convierte el silencio en información utilizable.
Por qué funciona: la belleza del patrón es que los dos desenlaces son distinguibles (fila vs vacío) sin que ninguno sea un error. Eso te deja ramificar con un simple IF en vez de tener que capturar y manejar errores.
Ejercicio 2 — Encuentra la carrera. Un compañero implementó esta deduplicación: (1) nodo Postgres que hace SELECT idempotency_key FROM processed_orders WHERE idempotency_key = $1; (2) un IF que revisa si vino algo; (3) en la rama "no vino nada", un nodo que crea el cobro y otro que hace INSERT INTO processed_orders .... Explica por qué, con el doble disparo casi simultáneo, esto puede cobrar dos veces, y cómo lo arreglarías con una sola sentencia.
Ver solución
El problema es el hueco entre el paso (1) y el paso (3). Con dos disparos casi simultáneos del mismo pedido:
- Disparo A hace el
SELECT: no encuentra la clave (todavía nadie la insertó). - Disparo B hace el
SELECTcasi al mismo tiempo: tampoco la encuentra, porque A aún no llegó a insertar. - Los dos van a la rama "no vino nada", los dos crean el cobro, y los dos insertan.
Es exactamente la condición de carrera: "verificar" y "actuar/registrar" son pasos separados, y entre ellos el otro disparo se cuela. El SELECT no protege nada, porque solo mira un instante que ya quedó atrás cuando el otro actúa.
Cómo arreglarlo: colapsar todo en una sola sentencia atómica:
INSERT INTO processed_orders (idempotency_key, order_id)
VALUES ($1, $2)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING idempotency_key;
Y ramificar según si devolvió fila (primera vez, crear el cobro) o vacío (duplicado, no hacer nada). Ahora "verificar" y "registrar" son el mismo gesto indivisible: la base serializa los dos disparos, uno gana el INSERT y el otro choca, y es imposible que los dos actúen.
Por qué funciona: el arreglo no es "hacer el SELECT más rápido" ni "poner un candado en el código". Es mover la decisión al único lugar que puede garantizar atomicidad —la base de datos— y dejar que su restricción de unicidad haga de árbitro.
Ejercicio 3 — Diseña la clave. Para cada caso de Cumbre, propón qué debería incluir la idempotency_key y explica por qué, cuidando no dejar pasar duplicados ni descartar pedidos legítimos.
(a) Un pedido web con un order_id que la tienda en línea garantiza único y estable.
(b) Un pedido rep_csv donde el mismo archivo puede subirse dos veces, y el order_id a veces viene vacío pero siempre están customer_id, created_at y las líneas.
(c) Un flujo donde un cliente puede, legítimamente, hacer dos pedidos idénticos en contenido el mismo día (misma lista de productos), y los dos deben cobrarse.
Ver solución
(a) La clave natural: el order_id solo. Si la tienda garantiza que es único y estable, ya identifica el trabajo perfectamente. Dos disparos del mismo pedido traen el mismo order_id y el segundo choca; dos pedidos distintos traen order_id distintos. No necesitas hash.
(b) Una clave sintética que combine campos estables, porque el order_id no es de fiar (a veces vacío). Un hash de, por ejemplo, customer_id + created_at + un resumen de las líneas identifica ese envío concreto sin depender del order_id faltante. La idea: elegir un conjunto de campos que, juntos, sean únicos para ese trabajo y estables entre reintentos.
(c) Aquí necesitas algo que distinga los dos pedidos legítimos, porque tienen el mismo contenido. Si tu clave fuera solo el contenido (cliente + líneas), los descartarías como duplicados y perderías la segunda venta —el falso duplicado de la lección 4—. La clave debe incluir algo que los diferencie: el order_id propio de cada uno si existe, o un identificador de pedido que el sistema asigne por cada compra. El contenido idéntico no los hace el mismo trabajo; son dos trabajos distintos que casualmente compran lo mismo.
Por qué funciona: fíjate en que la clave se diseña alrededor de una sola pregunta —¿qué hace que dos eventos sean "el mismo trabajo"?—. En (a) el order_id ya responde; en (b) hay que reconstruir la identidad con campos estables; en (c) hay que asegurarse de que dos compras reales no colisionen. La deduplicación es tan buena como la clave, y la clave es una decisión de diseño, no un detalle técnico.
Resumen y siguiente paso
En esta lección cerraste la trampa de "verificar y luego actuar". El problema era la condición de carrera: entre verificar si una clave existe y registrar que ahora existe hay un hueco por donde el doble disparo se cuela, y no lo cierras verificando con más cuidado. Lo cierras eliminando el hueco, haciendo que verificar y registrar sean una sola operación atómica.
Esa operación tiene dos piezas en Postgres. Una restricción de unicidad en la columna idempotency_key —una PRIMARY KEY o un UNIQUE— que hace que la base rechace cualquier segunda fila con la misma clave: la cerradura de la taquilla que solo admite un candado. Y la sentencia INSERT ... ON CONFLICT DO NOTHING RETURNING, que intenta insertar y, en un solo gesto indivisible, o inserta y te devuelve la clave (primera vez, actúa) o choca en silencio y devuelve vacío (duplicado, descarta). Con un IF que mira si vino la clave de vuelta, ramificas entre actuar y no actuar.
Viste cómo el nodo Postgres pasa los valores con marcadores $1, $2 por un canal separado del texto del SQL —para no abrir la puerta a la inyección— cómo calcular la idempotency_key con crypto en un nodo Code, y cómo la clave debe identificar el trabajo y no el intento para no dejar pasar duplicados ni descartar pedidos legítimos. Y lo probaste con ORD-2041: dos disparos separados por siete minutos, un solo cobro, las dos ejecuciones en verde.
Antes de avanzar deberías poder: explicar por qué un SELECT seguido de un INSERT no cierra la carrera; describir qué devuelve RETURNING en cada desenlace; y decir por qué nunca concatenas valores en el texto del SQL.
La lección 6 baja todo esto a tierra: cómo conectar estos nodos Postgres al Postgres real que ya trae el Self-Hosted AI Starter Kit v2, con la credencial configurada paso a paso, para que las tablas run_ledger y processed_orders vivan en una base de datos de verdad, corriendo en tu máquina, a costo cero. Hasta aquí diseñaste el mecanismo; ahora lo vas a enchufar.
Recursos
- PostgreSQL — INSERT ... ON CONFLICT — la referencia oficial de
ON CONFLICT DO NOTHING, la cláusulaRETURNINGy cómo se comporta la inserción ante un conflicto con una restricción de unicidad. La fuente exacta del patrón central de esta lección. - PostgreSQL — Constraints (Unique) — qué es una restricción de unicidad y una clave primaria, y la garantía de que la base rechaza filas duplicadas.
- Postgres node — n8n Docs — la operación Execute Query, los marcadores de parámetro
$1,$2,$3y la nota de que n8n sanitiza los datos de los parámetros para prevenir la inyección de SQL. Confirma ahí el formato exacto de tu versión. - Code node — n8n Docs — el nodo donde calculas la
idempotency_keyconcrypto, y sus límites en n8n 2.0. - IF node — n8n Docs — el nodo con el que ramificas entre "primera vez" y "duplicado" según lo que la compuerta devolvió.