El motor de idempotencia: garantizar la integridad transaccional
SchemaBridge Team · 2025-12-22 · Idempotency, Consistency, Transactions
Ejecución at-least-once, deduplicación de entrada y claves en el destino: dónde se detienen realmente los duplicados en un workflow distribuido y qué parte te toca a ti.
La pesadilla del doble cobro: por qué los sistemas distribuidos odian los reintentos
En nuestro mundo de redes inestables y recursos cloud efímeros, los fallos no solo son frecuentes: son un estado permanente. Todo ingeniero senior ha vivido la pesadilla de la "acción huérfana", un escenario que empieza como un pequeño fallo de red y termina en corrupción de datos o en una responsabilidad financiera. Es el escenario de fallo clásico de los sistemas distribuidos:
1. Petición: tu orquestador envía una petición de "cobro" a una API de pagos o de "envío" a una empresa de transporte.
2. Éxito: la API de terceros procesa la petición con éxito, cobra la tarjeta del cliente o imprime la etiqueta de envío.
3. Partición: se produce un breve fallo de red en el camino de vuelta. La respuesta de la API —la confirmación vital— nunca llega a tu máquina worker.
4. Reintento: tu worker, al ver un timeout, asume según la práctica habitual que el proceso falló. Sigue su política de reintentos y envía la petición de nuevo.
5. Duplicado: como la API no ha recibido una identidad única para esta intención concreta, procesa la petición otra vez. La ve como una transacción nueva. Al cliente se le cobra dos veces, o se generan dos etiquetas de envío para el mismo pedido.
Esto no es un fallo de la lógica del código; es un fallo de identidad. Sin una forma de identificar de manera única una intención concreta a través del tiempo y del espacio, tu sistema apuesta con los datos y el dinero de tus clientes cada vez que reintenta una conexión.
Las transacciones distribuidas han muerto: larga vida a la idempotencia
En una arquitectura monolítica confiamos en el Two-Phase Commit (2PC) o en locks distribuidos globales para garantizar la consistencia. Estas herramientas nos permiten tratar varias operaciones como una única unidad de verdad. Pero en un mundo fragmentado de APIs SaaS, workers serverless y microservicios políglotas, las transacciones globales son una fantasía. No escalan, introducen una latencia enorme y la mayoría de los proveedores externos no las soportan. Requieren un "bloqueo" de recursos imposible de lograr a través de los límites entre organizaciones.
El único camino viable hacia la consistencia distribuida es la idempotencia. Matemáticamente, una operación es idempotente si puede aplicarse varias veces sin cambiar el resultado más allá de la primera aplicación. En términos algebraicos: f(x) = f(f(x)). En términos de ingeniería, significa que tu sistema puede fallar y reintentar cualquier número de veces, y el resultado final siempre será el correcto.
El contrato honesto: at-least-once, con salvaguardas
Seamos precisos sobre lo que un orquestador puede y no puede prometer. SchemaBridge entrega el trabajo at-least-once. Un worker puede caerse después de que la llamada HTTP tuviera éxito pero antes de registrar el resultado; un mensaje puede volver a entregarse; un lease de claim puede expirar y ser tomado por otro. En cada uno de esos casos, el comportamiento seguro es volver a ejecutar el paso en lugar de perderlo, lo que significa que un paso puede, en raras ocasiones, ejecutarse dos veces.
Lo que hace la plataforma es que ese "raras ocasiones" sea lo más raro posible, en dos capas:
Capa 1: deduplicación de triggers entrantes
Cada disparo de un entry point recibe un ID de ejecución y, por defecto, ese ID se deriva del contenido: un SHA-256 determinista sobre el workspace, el vertex de entry point y el payload de la petición. La misma entrega de webhook que llega dos veces se resuelve en la misma ejecución en lugar de iniciar una segunda, que es exactamente lo que quieres cuando un emisor reintenta. Controlas la identidad con tres palancas:
| Palanca | Dónde | Úsala cuando |
| :--- | :--- | :--- |
| idempotencyFields | Configuración del vertex de entry point | Quieres deduplicar por un identificador de negocio (p. ej. order.id) en lugar de por el cuerpo completo |
| clientIdempotencyId | Parámetro de query en la llamada de trigger | El llamante es dueño de la clave de deduplicación |
| forceNewExecution | Solo ejecuciones de prueba y la llamada de ejecución en streaming (activo por defecto allí) | No disponible en triggers de producción: allí, usa idempotencyFields o un clientIdempotencyId único por llamada |
{
"type": "ENTRY_POINT",
"config": {
"idempotencyFields": "order.id"
}
}
La otra cara es una trampa real: si dos ejecuciones legítimas pueden llevar payloads idénticos byte a byte ("ejecuta el informe nocturno con un cuerpo vacío"), se fusionarán en una sola a menos que lo desactives. Los triggers programados (cron) son la excepción: cada disparo recibe un ID aleatorio nuevo, porque una programación nunca está pensada para deduplicarse. Y ten en cuenta que la deduplicación explícita es una búsqueda de lectura-y-luego-escritura, no una restricción de unicidad en base de datos, así que dos triggers realmente concurrentes con la misma clave pueden pasar ambos.
Capa 2: salvaguardas a nivel de paso
Dentro de una ejecución, un paso que ya está COMPLETED no se vuelve a ejecutar, un worker debe reclamar atómicamente un paso (DISPATCHED → EXECUTING) antes de ejecutarlo, las transiciones terminales están protegidas y una época de claim aísla a un worker cuyo lease fue tomado por otro. Estas salvaguardas detienen los caminos de duplicación habituales, pero ninguna puede entrar en una API de terceros y deshacer una llamada que ya ocurrió.
La capa 3 es tuya: la clave en el destino
Esa última brecha es la razón por la que el propio contrato de comportamiento de la plataforma lo dice claramente: haz que el efecto secundario sea idempotente en el destino y no confíes en que el orquestador lo ejecute exactamente una vez.
La buena noticia es que la mayoría de las APIs serias (pagos, envíos, mensajería) aceptan una clave de idempotencia, y un paso Gateway puede enviar una derivada de los datos de la ejecución. En SchemaBridge, el valor de un header puede ser una expresión sobre la entrada del paso, de modo que la clave se calcula a partir de un identificador de negocio estable, nunca a partir de una marca de tiempo o un valor aleatorio:
{
"type": "GATEWAY",
"config": {
"method": "POST",
"url": "https://api.payments.example/v1/charges",
"headers": {
"Idempotency-Key": "= 'charge-' & payload.order.id",
"Authorization": "Bearer {{ secrets.PAYMENTS_KEY }}"
}
}
}
Como la clave depende solo del pedido, una nueva ejecución de este paso —tras una reentrega, un Retry de un operador o un reintento automático por sobrecarga— envía la misma clave, y el destino devuelve el primer resultado en lugar de volver a cobrar. Una salvedad: Start over (una repetición del workflow completo) crea una nueva ejecución, pero si tu clave se deriva de datos de negocio y no del ID de ejecución, se mantiene estable incluso entonces. Precisamente por eso los identificadores de negocio son mejores claves que los identificadores del motor.
Cómo tratar APIs heredadas: el patrón "Read-Verify-Write"
Por desgracia, muchos sistemas heredados y proveedores SaaS de nicho no soportan claves de idempotencia. No tienen un header Idempotency-Key. Para estos endpoints "no idempotentes", puedes componer una secuencia Read-Verify-Write con pasos ordinarios:
1. Paso verificador (Read): un paso Gateway consulta primero el sistema de destino para ver si el registro ya existe (p. ej., GET /orders?external_id=123, usando el mismo identificador de negocio que habrías usado como clave).
2. Rama condicional: un edge condicional comprueba la respuesta. Si el pedido existe, la ejecución toma el camino de "ya hecho". Si no, continúa.
3. Paso de acción (Write): solo si el verificador no encuentra nada, la ejecución avanza a la escritura real (POST /orders).
Conoce la ventana
Read-Verify-Write estrecha la ventana de duplicados; no la cierra. Si un worker se cae después de la escritura pero antes de registrar el éxito, el paso de escritura se vuelve a ejecutar por sí solo: no vuelve atrás para verificar primero. Así que, para un destino sin ningún soporte de idempotencia, busca también una restricción de unicidad natural en el lado del destino (un external_id que rechace en caso de conflicto) y trata una respuesta de conflicto como "ya hecho". Por defecto, un 4xx hace fallar un paso Gateway; configurar clientErrorMode como complete convierte una respuesta de error de cliente en datos sobre los que tu siguiente paso puede enrutar.
La trampa de la gestión de claves: por qué la idempotencia casera falla a escala
Muchos equipos de ingeniería intentan construir una "tabla de idempotencia" en su base de datos principal. Esto crea tres problemas críticos que acaban con la velocidad y la fiabilidad:
1. El cuello de botella de escritura: cada llamada a la API requiere ahora una escritura en la base de datos para registrar el token. Bajo carga elevada, tu tabla de idempotencia se convierte en un punto de contención.
2. Complejidad de limpieza, el problema de la basura: las claves de idempotencia no se necesitan para siempre. Necesitas un proceso en segundo plano o un TTL (Time-To-Live) para purgar las claves antiguas. Si la purga es demasiado agresiva, te arriesgas a dobles cobros en tareas lentas y reintentadas. Si es demasiado lenta, la tabla crece sin límite.
3. El desajuste de estado distribuido: ¿qué pasa si la escritura en la base de datos tiene éxito pero la llamada a la API falla? ¿O si tu worker se cae después de la llamada a la API pero antes de que la base de datos pueda actualizarse a "Terminado"? Acabas con un desajuste que requiere intervención manual para resolverse.
La salida no es una tabla más inteligente de tu lado; es dejar que el sistema que ejecuta el efecto secundario se encargue de la deduplicación. Un destino que respeta una clave de idempotencia ya ha resuelto el almacenamiento, la expiración y la ventana de caída para su propia operación: tu trabajo es enviarle una clave estable.
Generación del token en el cliente frente al servidor
¿Dónde debería generarse el token?
- En el cliente (en el origen de la intención): el llamante deriva el token antes del primer intento y lo reutiliza en cada reintento. Es la única ubicación que protege contra el "fallo en el camino de vuelta" descrito al principio de este artículo.
- En el servidor: el receptor genera un ID (normalmente un ID de base de datos). Es útil para la consistencia interna, pero no ayuda a un llamante cuya confirmación se perdió.
En un workflow de SchemaBridge, el origen de la intención son los propios datos de la ejecución. Derivar la clave ahí, en el header del paso Gateway, la mantiene estable en todos los caminos de reintento que pueda tomar el motor.
Tabla comparativa: dónde se detienen los duplicados
| Origen del duplicado | Detenido por | Quién lo configura |
| :--- | :--- | :--- |
| El emisor reintenta el mismo webhook | ID de ejecución derivado del contenido / idempotencyFields / clientIdempotencyId | SchemaBridge, con la clave que elijas |
| Paso reentregado dentro de una ejecución | Salvaguardas a nivel de paso (cortocircuito si ya está completado, claim atómico, época de claim) | SchemaBridge |
| El paso se reejecuta después de que la llamada ya tuvo éxito | Clave de idempotencia en el destino | Tú, en el paso Gateway |
| El destino no soporta idempotencia | Read-Verify-Write más una restricción de unicidad en el destino | Tú |
Consejos de experto: la checklist de idempotencia
1. Nunca uses marcas de tiempo: tu clave debe basarse en los datos, no en el tiempo.
2. Acota tus claves: antepón a las claves un prefijo por operación (charge-, ship-) para que una clave de "Envío" nunca colisione con una de "Facturación", aunque compartan el ID de pedido.
3. Gestiona los conflictos 409: si una API devuelve un 409 (Conflict) para una clave o identificador que ya ha visto, tu workflow normalmente debería tratarlo como "ya hecho" si la entrada coincide.
4. Decide de antemano sobre los payloads repetidos: si dos ejecuciones genuinas pueden llevar cuerpos idénticos, configura idempotencyFields o pasa un clientIdempotencyId único por llamada; de lo contrario, la segunda ejecución se eliminará por deduplicación. (forceNewExecution solo existe en las ejecuciones de prueba y en la llamada de ejecución en streaming).
5. Haz que la clave forme parte de la plantilla: si un desarrollador tiene que acordarse de añadir una clave de idempotencia, tarde o temprano se le olvidará. Ponla en la configuración de Gateway que reutilizas para esa API.
Conclusión: la identidad es la columna vertebral de la verdad
En un sistema distribuido no puedes confiar en la red, no puedes confiar en el reloj y no puedes confiar en la respuesta. Lo único en lo que realmente puedes confiar es la identidad.
SchemaBridge te da identidad en los bordes que controla —IDs de ejecución derivados del contenido, claves de deduplicación explícitas y salvaguardas a nivel de paso— y es honesto sobre el borde que no controla: el efecto secundario en el tercero. Deriva una clave estable de tus datos de negocio, envíala con cada llamada, y la "pesadilla del doble cobro" se convierte en una decisión de diseño que tomaste una vez en lugar de un incidente que descubres más tarde.
Parte de nuestra serie Building the Bridge. Consulta también: Dominar el merge sobre la sincronización del estado entre ramas paralelas.