Módulo 2: Idempotencia: que repetir no duplique

6. La trampa de "verificar y luego actuar"

Descripción

Al terminar esta lección vas a poder reconocer, y evitar, el error más sutil de todo el módulo: el patrón de "verifico si existe y si no, lo creo", que parece idempotente y no lo es. Vas a entender por qué falla —una condición de carrera, el hueco entre verificar y actuar donde cabe otra ejecución—, por qué ese fallo sobrevive intacto a todas tus pruebas y explota solo en producción, y por qué la solución correcta no vive en dos nodos separados sino dentro de una sola operación atómica: el upsert de la lección 4 o la cabecera de la lección 5.

Esto importa porque el patrón "verificar y luego actuar" es la solución que a todos se nos ocurre primero. Es intuitivo, se lee bien, y funciona en cada prueba que le hagas. Por eso es tan peligroso: no lo descubres tú, lo descubre producción, semanas después, con un cobro duplicado que juraste haber prevenido. Esta lección es la vacuna. Es, probablemente, la lección que más separa a quien entendió idempotencia de quien solo copió un upsert sin saber por qué era mejor que dos nodos.

Conexión con el módulo: las lecciones 4 y 5 te dieron las soluciones robustas —el upsert atómico y la cabecera de idempotencia—. Esta lección explica por qué son robustas, contrastándolas con la alternativa tentadora que no lo es. En la lección 5 dejé sembrada dos veces la frase "ventana de carrera"; aquí la cosechamos. Es la lección más conceptual del módulo, y su lección se aplica a todo lo que sigue: la coordinación del módulo 5 y los reintentos del módulo 6 solo son seguros si evitas este patrón. El proyecto de la lección 8 te va a pedir, explícitamente, que no caigas en él.

El patrón que a todos se nos ocurre

Cuando una API no te da una cabecera de idempotencia, o cuando escribes en un sistema sin restricción de unicidad, el instinto universal es este:

1. Verifica: ¿ya existe un cobro para ORD-2041?
2. Si NO existe → créalo.
   Si SÍ existe → no hagas nada.

En n8n se ve como tres nodos encadenados:

HTTP Request          If                    HTTP Request
(GET: ¿existe    ──►  (¿la respuesta   ──►  (POST: crear el cobro,
 el cobro?)            dice que no?)          solo si el If deja pasar)

Léelo y dime si no suena perfecto. "Antes de cobrar, reviso si ya cobré; si ya cobré, no vuelvo a cobrar." Es exactamente lo que quieres que pase. Y por eso es tan traicionero: la lógica es correcta, el problema es el tiempo.

Por qué falla: el hueco entre verificar y actuar

Aquí está el corazón de la lección, y vale la pena leerlo despacio.

Entre el paso 1 (verificar) y el paso 2 (crear) pasa tiempo. No mucho —milisegundos, quizás— pero pasa. El nodo HTTP Request que verifica termina, el nodo If evalúa, y recién entonces el nodo HTTP Request que crea empieza. En ese intervalo, tu ejecución no tiene el control exclusivo del mundo. Otra cosa puede pasar.

¿Qué otra cosa? Otra ejecución del mismo workflow, disparada por la segunda llegada del webhook. Recuerda: el webhook de Cumbre se dispara dos veces, casi al mismo tiempo. n8n puede procesar las dos ejecuciones en paralelo —sobre todo con varios workers, pero incluso sin ellos, dos disparos casi simultáneos se solapan—. Y ahí ocurre el desastre. Sigamos el reloj, ejecución A y ejecución B, con el mismo ORD-2041:

t=0ms   A: GET ¿existe cobro para ORD-2041?  → la API responde: NO
t=1ms   B: GET ¿existe cobro para ORD-2041?  → la API responde: NO
                                                 (¡sigue sin existir! A aún no lo creó)
t=2ms   A: el If deja pasar → POST crear cobro → se crea ch_777
t=3ms   B: el If deja pasar → POST crear cobro → se crea ch_888

Léelo otra vez. Las dos ejecuciones preguntaron "¿existe?". Las dos recibieron "no", porque en el instante en que preguntaron, de verdad no existía —A todavía no había llegado al paso de crear—. Las dos pasaron el If. Y las dos crearon un cobro. Resultado: ch_777 y ch_888. Dos cobros. El patrón "verificar y luego actuar", diseñado para prevenir exactamente esto, no previno nada.

El problema no es que la lógica esté mal escrita. La lógica es impecable. El problema es que la verificación y la acción son dos operaciones separadas, con un hueco en medio, y en ese hueco la respuesta de la verificación deja de ser verdad. B verificó "no existe", y para cuando B actuó, esa respuesta ya estaba obsoleta —A la había vuelto falsa—. B actuó sobre información vencida.

Este fenómeno tiene un nombre en el diseño de sistemas: una condición de carrera (race condition). Se llama así porque dos ejecuciones "compiten" y el resultado depende de quién llega primero a cada paso, un detalle que tú no controlas. El caso específico —verificar un estado y actuar sobre esa verificación, cuando entre medias el estado pudo cambiar— se conoce como TOCTOU, del inglés time-of-check to time-of-use: el tiempo entre que revisas algo y que lo usas. Entre el "check" y el "use" cabe el desastre.

La analogía: el último asiento del vuelo

Piénsalo con dos personas comprando el último asiento de un vuelo, cada una en su propia computadora, al mismo tiempo.

Ana abre la página: "queda 1 asiento". Beto abre la página en el mismo segundo: "queda 1 asiento" —los dos ven el mismo asiento disponible, porque ninguno ha comprado todavía—. Ana hace clic en "comprar". Beto, un instante después, también hace clic en "comprar". Si el sistema de la aerolínea está mal hecho, vende el asiento dos veces: Ana y Beto llegan al aeropuerto con boletos para el mismo lugar. Los dos "verificaron" que había asiento, los dos "actuaron" comprando, y entre su verificación y su compra, la disponibilidad que vieron dejó de ser cierta.

Una aerolínea seria no funciona así. Cuando Beto hace clic en "comprar", el sistema no confía en lo que Beto vio hace un instante; en el momento mismo de vender, vuelve a comprobar la disponibilidad y la reserva en una sola operación indivisible, de modo que si Ana ya se lo llevó, la compra de Beto falla ahí mismo con "asiento no disponible". La comprobación y la venta ocurren pegadas, sin hueco. Eso es exactamente lo que hace un upsert, y es lo que "verificar y luego actuar" no puede hacer, porque son dos pasos con aire en medio.

Por qué sobrevive a tus pruebas

Esta es la parte cruel, y la razón por la que este bug es tan caro.

Cuando pruebas tu workflow, lo disparas una vez. Miras el resultado, un solo cobro, todo bien. Quizás lo disparas una segunda vez para "probar la idempotencia" —pero lo disparas después de que la primera terminó, secuencialmente—. La segunda ejecución verifica, encuentra que el cobro ya existe (porque la primera ya terminó de crearlo), y no crea otro. ¡Funciona! Concluyes que el patrón es idempotente y lo mandas a producción.

El problema es que tu prueba nunca reprodujo la condición que rompe el patrón: dos ejecuciones solapadas en el tiempo. Probaste A-luego-B (secuencial), y en secuencial el patrón funciona de verdad. Lo que falla es A-y-B-a-la-vez (concurrente), y eso solo ocurre cuando el webhook se dispara doble en producción, con las dos ejecuciones corriendo casi juntas. Tu prueba manual, hecha por un humano que hace clic una vez y espera, es incapaz de crear ese solapamiento.

Por eso el patrón "verificar y luego actuar" es un lobo con piel de oveja: pasa todas las pruebas razonables y falla solo bajo concurrencia real. Es la definición de un bug que sobrevive al desarrollo y explota en producción. Y cuando explota, es difícil de diagnosticar, porque cuando vas a reproducirlo —disparando el workflow a mano— vuelve a funcionar, y te quedas mirando la pantalla sin entender por qué en producción sí falla. La respuesta es siempre la misma: en producción hubo concurrencia; en tu prueba, no.

Guárdate esta pregunta, porque es la que desactiva el bug antes de que nazca: "¿qué pasa si dos copias de este workflow corren al mismo tiempo con el mismo evento?" Si la respuesta involucra "las dos verifican y las dos actúan", tienes una condición de carrera, sin importar cuán bien funcione en tu prueba secuencial. Es la misma pregunta que la lección 3 te enseñó para las claves, girada hacia la concurrencia: allá era "¿mi clave sería idéntica si el evento llegara otra vez?"; aquí es "¿mi lógica seguiría siendo correcta si el evento llegara otra vez al mismo tiempo?". Las dos preguntas, hechas por reflejo, atrapan la enorme mayoría de los bugs de duplicado antes de que lleguen a producción.

La solución: una sola operación atómica

Si el problema es el hueco entre verificar y actuar, la solución es eliminar el hueco: hacer que la verificación y la acción sean la misma operación indivisible, que nadie pueda partir por la mitad. Eso es lo que significa atómico: una operación atómica ocurre entera o no ocurre, y nada puede colarse en su interior.

¿Y quién sabe hacer operaciones atómicas? La base de datos. Y la API bien diseñada. Justamente las dos herramientas de las lecciones 4 y 5.

El upsert es atómico. Cuando escribes INSERT ... ON CONFLICT (order_id) DO NOTHING, no estás haciendo "verifica y luego inserta" en dos pasos. Estás dando una sola instrucción, y la base de datos, por dentro, garantiza que la comprobación del conflicto y la inserción ocurran pegadas, protegidas por la restricción de unicidad. Volvamos al reloj, ahora con el upsert:

t=0ms   A: INSERT ORD-2041 ON CONFLICT DO NOTHING  → inserta ch_777
t=1ms   B: INSERT ORD-2041 ON CONFLICT DO NOTHING  → choca con la restricción
                                                       de unicidad → DO NOTHING

Cuando B intenta insertar, la base de datos ya tiene la fila de A —porque la restricción de unicidad es un candado que A tomó al insertar— y el DO NOTHING absorbe el intento de B. No hay hueco donde B pueda "ver que no existe", porque B no verifica por su cuenta: le pide a la base de datos que inserte, y la base de datos, en una sola operación, decide que ya existe. La atomicidad la pone el motor de la base de datos, no tu workflow.

La cabecera de idempotencia es atómica del lado del servidor. Cuando mandas dos veces la misma Idempotency-Key, la pasarela hace la comprobación "¿ya vi esta clave?" y la acción "crea o devuelve el existente" como una sola operación protegida de su lado. Igual que la aerolínea seria que comprueba-y-reserva en un paso. Tú no tienes el hueco porque el hueco lo cerró el servidor.

La diferencia clave, entonces, entre el patrón frágil y el robusto no es cuántos nodos usas ni qué tan lista es tu lógica. Es quién hace cumplir la unicidad. En "verificar y luego actuar", la haces cumplir , con dos nodos y un If, y no puedes hacerla cumplir de forma atómica porque n8n ejecuta los nodos uno tras otro, con tiempo entre ellos. En el upsert, la hace cumplir la base de datos, que sí sabe ser atómica. Delegas la unicidad a quien sabe garantizarla sin huecos.

Ejemplo trabajado: el mismo objetivo, el nodo frágil y el nodo robusto

Cumbre necesita registrar en su tabla orders que ORD-2041 fue procesado, sin duplicar. Veamos las dos formas.

La forma frágil (tres nodos):

Postgres (SELECT)         If                     Postgres (INSERT)
SELECT * FROM orders  ──► ¿la consulta      ──►  INSERT INTO orders
WHERE order_id =           devolvió 0 filas?      VALUES (...)
'ORD-2041'                 (o sea, no existe)

Qué esperar en una prueba secuencial: funciona. Disparas una vez, inserta. Disparas otra vez (después), el SELECT encuentra la fila, el If no deja pasar, no inserta. Una sola fila. Parece idempotente.

Qué esperar bajo concurrencia real: falla. Dos ejecuciones solapadas hacen los dos SELECT antes de que cualquiera inserte, las dos ven "0 filas", las dos pasan el If, las dos insertan. Dos filas. Y si la tabla ni siquiera tiene restricción de unicidad, ahí se quedan las dos, sin error.

La forma robusta (un nodo):

Postgres (Execute Query)
INSERT INTO orders (order_id, customer_id, amount, status)
VALUES ('ORD-2041', 'CUST-118', 1780, 'pending')
ON CONFLICT (order_id) DO NOTHING;

Qué esperar bajo concurrencia real: funciona. Las dos ejecuciones mandan el mismo INSERT ... ON CONFLICT. La primera inserta; la segunda choca con la restricción de unicidad y el DO NOTHING la absorbe. Una sola fila, incluso corriendo exactamente al mismo tiempo. Un nodo, no tres, y encima más robusto.

Detente en la ironía: la solución robusta tiene menos nodos que la frágil. No estás agregando complejidad para ganar seguridad; estás quitando complejidad. El SELECT + If no solo era inseguro, era trabajo de más. Delegar la unicidad a la base de datos es más simple y más correcto. Casi nunca se pueden tener las dos cosas a la vez; aquí sí.

Pero a veces no hay upsert ni cabecera: ¿entonces qué?

Seamos honestos: hay situaciones donde ni la API ofrece cabecera, ni el sistema tiene restricción de unicidad, ni puedes fijar por PUT. ¿Estás condenado a la condición de carrera?

No, pero la solución no es "hacer el SELECT+If con más cuidado" —eso no cierra el hueco—. La solución es traer la atomicidad a un lugar que tú controles: tu propia base de datos.

La idea, que el módulo 4 desarrolla a fondo, es esta: antes de disparar el efecto frágil, registras el evento en una tabla tuya con restricción de unicidad, mediante un upsert. Si el upsert inserta (la clave era nueva), procedes con el efecto. Si el upsert no inserta (la clave ya estaba), no disparas el efecto, porque alguien más ya lo hizo. La restricción de unicidad de tu tabla se convierte en el árbitro atómico que decide quién tiene permiso de disparar el efecto —y como es atómica, dos ejecuciones concurrentes no pueden las dos "ganar" el permiso—.

En otras palabras: cuando no puedes hacer el efecto mismo atómico, haces atómica la decisión de dispararlo, apoyándote en tu propia base de datos. Es "verificar y actuar", pero con el "verificar" convertido en un upsert atómico en vez de un SELECT+If. El hueco desaparece porque la verificación ahora es indivisible.

No lo construyas todavía —es el ledger del módulo 4—. Por ahora quédate con el principio: la unicidad siempre la tiene que hacer cumplir algo que sepa ser atómico (una base de datos con restricción de unicidad, o una API del lado del servidor), nunca dos nodos separados en tu workflow. Si te encuentras armando un SELECT+If+INSERT, detente: hay una forma atómica, y si no la ves, es que el árbitro atómico tiene que ser tu propia tabla.

Una variante honesta: deja que la base de datos diga "no"

Hay una forma de pensar la idempotencia que a veces resulta más natural que el DO NOTHING, y que conviene conocer porque aparece mucho en la vida real: intenta la operación, y si la base de datos la rechaza por violar la unicidad, trata ese rechazo como la señal de "ya estaba hecho".

En vez de ON CONFLICT DO NOTHING, haces un INSERT normal —sin cláusula de conflicto— y confías en que la restricción de unicidad falle cuando la clave ya existe:

t=0ms   A: INSERT ORD-2041   → éxito, insertó
t=1ms   B: INSERT ORD-2041   → ERROR: viola la restricción de unicidad

El error de B no es un problema; es información. La base de datos te está diciendo, de forma atómica y sin huecos, "esta clave ya existe, no la volví a insertar". Tu workflow, en vez de tratar ese error como un fallo, lo reconoce como "duplicado detectado" y sigue su camino tranquilo: el efecto ya lo hizo la ejecución A.

Esto es sutil pero importante: sigue siendo atómico. La restricción de unicidad es el árbitro, igual que con DO NOTHING; la única diferencia es que aquí el "ya existe" te llega como un error que tú interpretas, en vez de como un silencio. Es la misma protección con otra ergonomía. La eliges cuando quieres saber explícitamente que hubo un duplicado —para contarlo, para registrarlo, para no seguir con pasos que solo tienen sentido en la primera vez—.

La conexión con lo que viene: manejar ese error con elegancia —distinguir "error de unicidad = duplicado esperado, sigo tranquilo" de "error de verdad = algo se rompió, hay que alertar"— es justo el tipo de manejo de errores que el módulo 6 trata a fondo. Por ahora, quédate con que un error de unicidad no siempre es un fallo: a veces es tu mecanismo de idempotencia funcionando, diciéndote en voz alta lo que el DO NOTHING te dice en silencio.

Lo que no es esta variante: no es "verificar y luego actuar". Fíjate en la diferencia crucial. Aquí no hay un SELECT previo que verifica; hay un INSERT directo que intenta y deja que la base de datos decida atómicamente. No hay hueco entre verificar y actuar porque no hay una verificación separada: el intento es la verificación. Esa es toda la diferencia entre lo frágil y lo robusto.

Errores comunes

Creer que "verificar y luego actuar" es idempotente porque funcionó en la prueba (conceptual). Qué pasa: se arma el patrón de tres nodos, se prueba disparando una y luego otra vez, funciona las dos veces, y se despliega con confianza. En producción, bajo concurrencia, duplica. Por qué pasa: la prueba manual es secuencial —un humano dispara, espera, dispara— y en secuencial el patrón sí funciona; la concurrencia que lo rompe solo aparece con disparos casi simultáneos, que una persona no puede reproducir a mano. Cómo detectarlo: hazte la pregunta de la lección —"¿qué pasa si dos copias corren al mismo tiempo con el mismo evento?"— en vez de fiarte de la prueba secuencial. Si la respuesta es "las dos verifican, las dos crean", es frágil, funcione o no en tu prueba. Cómo corregirlo: reemplaza el SELECT+If+INSERT por un INSERT ... ON CONFLICT (upsert) atómico, o por la cabecera de idempotencia si el efecto es una API. Delega la unicidad a algo que sepa ser atómico.

Agregar el SELECT de verificación encima de un upsert (práctico). Qué pasa: alguien, por exceso de celo, pone un SELECT que verifica si existe antes de un upsert que ya de por sí no duplica. El SELECT no solo es innecesario —el upsert ya maneja el caso "ya existe"— sino que reintroduce un hueco y da una falsa sensación de que "la verificación" es la que protege. Por qué pasa: no se confía del todo en el upsert, o no se entiende que el upsert ya incluye la verificación de forma atómica. Cómo detectarlo: si tienes un SELECT seguido de un upsert sobre la misma clave, el SELECT sobra. Cómo corregirlo: quita el SELECT. El upsert hace la comprobación y la acción en una sola operación atómica; anteponerle una verificación manual no agrega seguridad, agrega ruido y una ventana. Confía en la atomicidad de la base de datos.

Suponer que n8n nunca corre el mismo workflow en paralelo (conceptual). Qué pasa: se razona "mi instancia es chica, corre un solo proceso, así que no hay concurrencia" y se deja el SELECT+If+INSERT. Luego el webhook se dispara doble, las dos ejecuciones se solapan lo suficiente, y duplica. Por qué pasa: se subestima cuánto se solapan dos ejecuciones disparadas casi al mismo tiempo; incluso sin varios workers, el procesamiento de dos webhooks concurrentes puede intercalarse, y con queue mode o varios workers la concurrencia es explícita. Cómo detectarlo: no asumas nada sobre la concurrencia de tu instancia; diseña como si dos ejecuciones pudieran solaparse, porque en algún momento lo harán. Cómo corregirlo: usa siempre operaciones atómicas para la unicidad. La atomicidad del upsert protege igual de bien tanto si hay un worker como si hay diez; no depende de cuánta concurrencia real tengas, y por eso es la opción segura por defecto.

Ejercicios

Ejercicio 1 — Encuentra el hueco. Este es el diseño de un compañero para no duplicar el envío de un correo. Traza la línea de tiempo de dos ejecuciones concurrentes (A y B) del mismo evento y muestra cómo termina enviando dos correos. Después di dónde está el hueco.

1. HTTP Request (GET): ¿existe un registro "correo enviado para ORD-2041"?
2. If: ¿la respuesta dice que NO existe?
3. HTTP Request (POST): enviar el correo
4. HTTP Request (POST): registrar "correo enviado para ORD-2041"
Ver solución

La línea de tiempo:

t=0   A: GET ¿existe registro? → NO
t=1   B: GET ¿existe registro? → NO   (A aún no registró nada)
t=2   A: If deja pasar → envía el correo (correo #1)
t=3   B: If deja pasar → envía el correo (correo #2)   ← DUPLICADO
t=4   A: registra "enviado para ORD-2041"
t=5   B: registra "enviado para ORD-2041"   (o falla si hay unicidad, pero el correo ya salió)

El hueco está entre el paso 1 (verificar) y el paso 3 (enviar). Las dos ejecuciones verifican "no existe" en los pasos 0 y 1, antes de que ninguna registre nada, así que las dos pasan el If y las dos envían. El registro del paso 4 llega después del envío, demasiado tarde para evitar el segundo correo.

Y hay un agravante propio de los correos: aunque pusieras una restricción de unicidad en el registro del paso 4 —de modo que el segundo registrar fallara— el correo #2 ya salió en el paso 3. Un correo no se puede des-enviar. Para efectos irreversibles, verificar-y-actuar es especialmente peligroso, porque el daño ocurre antes de que el registro pueda impedirlo.

La corrección: invierte el orden y hazlo atómico. Primero registra el evento con un upsert atómico (INSERT ... ON CONFLICT DO NOTHING); solo si el upsert insertó (la clave era nueva), envía el correo. Así, de dos ejecuciones concurrentes, solo una "gana" el upsert y envía; la otra choca con la unicidad y no envía. La atomicidad del upsert decide quién tiene permiso, antes de disparar el efecto irreversible.

Por qué funciona: viste que el hueco no está donde uno miraría primero (el envío), sino en la verificación que ocurre demasiado pronto y demasiado separada de la acción. Y viste que para efectos irreversibles hay que ganar el permiso de forma atómica antes de actuar, no verificar después.

Ejercicio 2 — ¿Por qué el upsert no tiene el hueco? En tus palabras, explica por qué INSERT ... ON CONFLICT (order_id) DO NOTHING no sufre la condición de carrera que sí sufre el SELECT+If+INSERT, aunque las dos "verifican si existe y actúan según eso".

Ver solución

La diferencia es quién verifica y cuándo, y sobre todo si la verificación y la acción son separables.

En SELECT+If+INSERT, verificas (con el SELECT), y la verificación es una operación completa que termina antes de que empiece la acción (el INSERT). Entre las dos hay un hueco de tiempo, y como son operaciones distintas ejecutadas por nodos distintos, otra ejecución puede colarse en ese hueco y volver obsoleta tu verificación.

En INSERT ... ON CONFLICT DO NOTHING, la base de datos verifica, y lo hace dentro de la misma operación indivisible que la inserción. No hay un momento en que la verificación haya terminado pero la inserción no haya empezado; son una sola cosa. La restricción de unicidad actúa como un candado: cuando A inserta, toma el candado sobre ORD-2041, y cuando B intenta insertar, se topa con ese candado y el DO NOTHING lo absorbe. B nunca ve un momento en que ORD-2041 "no existe todavía pero yo puedo insertar", porque la base de datos serializa esos intentos de forma atómica.

En una frase: el SELECT+If+INSERT parte la verificación y la acción en dos, dejando un hueco; el upsert las funde en una operación atómica sin hueco, y delega el arbitraje a la restricción de unicidad de la base de datos.

Por qué funciona: nombraste la propiedad exacta que hace la diferencia —atomicidad— y viste que no es cuestión de "verificar mejor", sino de que verificar y actuar sean inseparables. Ningún cuidado extra en el SELECT cierra el hueco; solo fundirlo con la acción lo cierra.

Ejercicio 3 — Rediseña sin la trampa. Cumbre llama a una API de inventario que no ofrece cabecera de idempotencia ni permite PUT por id: solo tiene GET /stock/{sku} y POST /stock/reserve. Un compañero propone: "hago GET para ver si ya reservé, y si no, hago POST para reservar". Explica por qué eso puede reservar dos veces bajo concurrencia, y propón un rediseño que use tu propia base de datos como árbitro atómico.

Ver solución

Por qué el diseño del compañero falla: es un verificar-y-actuar clásico. Dos ejecuciones concurrentes del mismo evento hacen los dos GET antes de que cualquiera reserve, las dos ven "no reservado", y las dos hacen el POST /stock/reserve. Doble reserva. La API de inventario no puede impedirlo porque no ofrece ningún mecanismo de idempotencia, y el hueco entre el GET y el POST es donde entra la segunda ejecución.

El rediseño con árbitro atómico propio:

1. Postgres (upsert): INSERT INTO reservations (idempotency_key)
   VALUES ('<clave de ORD-2041>')
   ON CONFLICT (idempotency_key) DO NOTHING
   RETURNING idempotency_key;     -- devuelve la clave SOLO si insertó

2. If: ¿el upsert devolvió una fila? (es decir, ¿ESTA ejecución fue la que insertó?)
   - SÍ  → esta ejecución "ganó el permiso" → POST /stock/reserve
   - NO  → otra ejecución ya reservó → no hagas nada

La clave del rediseño: la decisión de reservar se vuelve atómica, apoyándose en la restricción de unicidad de tu tabla reservations. De dos ejecuciones concurrentes, el upsert solo deja que una inserte la clave (la otra choca con la unicidad y no obtiene la fila de vuelta). Solo la que insertó dispara el POST. La API de inventario sigue sin ser idempotente, pero ya no la llamas dos veces, porque tu base de datos decidió atómicamente quién tenía permiso de llamarla.

(Este es exactamente el patrón que el módulo 4 formaliza como ledger de deduplicación, y que el módulo 5 usa como base del patrón outbox. Aquí solo viste su esencia.)

Por qué funciona: entendiste que cuando el efecto no puede ser atómico, mueves la atomicidad a la decisión de dispararlo, y esa decisión sí puede vivir en un upsert sobre tu propia tabla. La unicidad la hace cumplir algo que sabe ser atómico —tu base de datos— y no dos nodos separados.

Resumen y siguiente paso

En esta lección desactivaste la trampa más sutil del módulo: el patrón "verifico si existe y si no, lo creo", que parece idempotente y no lo es. Viste por qué falla —el hueco entre verificar y actuar, donde una segunda ejecución concurrente se cuela, verifica "no existe" con información que un instante después deja de ser cierta, y crea el duplicado—, un fenómeno con nombre propio: condición de carrera, y en su forma específica, TOCTOU (time-of-check to time-of-use). Entendiste por qué el bug es tan caro: sobrevive a toda prueba secuencial y solo explota bajo la concurrencia real de producción, que un humano haciendo clic no puede reproducir. Y viste la solución: eliminar el hueco haciendo que verificar y actuar sean una sola operación atómica —el upsert ON CONFLICT de la lección 4 o la cabecera de la lección 5—, delegando la unicidad a algo que sabe ser atómico (la base de datos, la API del lado del servidor) en vez de a dos nodos separados. Y para cuando no hay upsert ni cabecera, el principio que el módulo 4 construirá: traer la atomicidad a tu propia tabla, haciendo atómica la decisión de disparar el efecto.

Antes de avanzar a la lección 7 deberías poder: reconocer un SELECT/GET + If + INSERT/POST como una condición de carrera; explicar por qué el upsert no tiene ese hueco; y hacerte, ante cualquier efecto, la pregunta que desactiva el bug —"¿qué pasa si dos copias corren al mismo tiempo con el mismo evento?"—.

Hasta aquí, los efectos los disparaba tu workflow de forma directa: un HTTP Request que tú pusiste, un nodo de base de datos que tú configuraste. La lección 7 sube la apuesta con el caso de moda: un AI Agent que decide por su cuenta qué herramienta llamar y cuándo. Un agente puede llamar una herramienta con efecto —cobrar, enviar, crear— dos veces, o dar salidas ligeramente distintas en cada corrida por no ser determinista. Vas a ver cómo envolver esas herramientas con una clave de idempotencia para que el bucle del agente no ejecute el mismo efecto dos veces, aplicando todo lo de este módulo —incluida, muy en especial, la lección que acabas de aprender sobre no caer en verificar-y-actuar—.

Recursos