Dominar el merge: coordinar el estado en mundos paralelos

SchemaBridge Team · 2025-12-29 · Concurrency, State Management, Synchronization

Sincronizar ramas paralelas sin condiciones de carrera: joins duraderos, el problema de la cola larga y los valores por defecto que deciden qué join obtienes.

La paradoja del paralelismo: libertad frente a sincronización

En nuestra búsqueda de rendimiento hemos abrazado el paralelismo. Repartimos nuestras tareas, lanzamos workers asíncronos y dispersamos los datos entre muchos nodos. Ganamos throughput, pero pagamos un precio alto en complejidad. Lo difícil de los sistemas distribuidos no es arrancar cosas a la vez, sino volver a reunirlas.

Imagina un proceso complejo de gestión de pedidos: lanzas simultáneamente una tarea «Cobrar tarjeta», una tarea «Comprobar inventario» y una tarea «Calcular envío». Para pasar al siguiente paso —imprimir la factura— necesitas los resultados de las tres. Esto es un vértice Merge, también conocido como join distribuido o sincronizador de barrera.

En un entorno de un solo hilo esto es sencillo: basta con esperar a que vuelvan tres llamadas a funciones. Pero en un motor distribuido estas tareas se ejecutan en máquinas distintas y pueden terminar con segundos o incluso horas de diferencia. Una puede fallar mientras las demás tienen éxito. Esta es la paradoja del paralelismo: cuanto más paralelizas tu trabajo para ganar velocidad, más difícil resulta coordinar el resultado final.

La evolución histórica de la sincronización por barrera

Para entender por qué fusionar estado es tan difícil, hay que mirar a la historia de la computación de alto rendimiento (HPC). En los años 70 y 80, los científicos de la computación desarrollaron el concepto de barrera. Una barrera es un punto de sincronización en el que cada hilo de un proceso paralelo debe detenerse y esperar hasta que todos los demás hilos hayan llegado. Solo cuando se alcanza el recuento puede avanzar el proceso.

En los sistemas monolíticos esto se implementaba con spin-locks o mutex en memoria compartida. La CPU de la máquina gestionaba el estado de la barrera a una velocidad casi infinita. Pero al pasar a los sistemas distribuidos perdimos la «memoria compartida». Ya no había una única CPU que actuara como árbitro.

En la década de 2000 vimos el auge de Map-Reduce (el artículo fundacional de Google). Map-Reduce ofrecía una escala enorme para el procesamiento paralelo, pero estaba diseñado para «cargas de trabajo por lotes». Mapeabas los datos y después había una fase «Reduce» que agregaba todos los resultados. Una tarea de map fallida simplemente se volvía a ejecutar, pero una lenta retrasaba la fase de reduce; el diseño de Google combatía esos rezagados con tareas de respaldo, que reejecutaban de forma especulativa las últimas tareas en curso cerca del final del trabajo. Esto nos lleva al reto operativo más importante al fusionar estado: el problema de la cola larga.

El problema de la cola larga: gana la rama más lenta

En un join distribuido de 1.000 elementos, el tiempo total de ejecución no lo determina la velocidad media de tus workers, sino la latencia del worker más lento. Si 999 elementos terminan en 10 ms, pero uno tarda 10 segundos por un bloqueo de base de datos o un fallo puntual de red, todo tu workflow espera 10 segundos.

Este es el problema de la cola larga. En una implementación ingenua provoca una enorme acumulación de uso de recursos. Mientras 999 hilos esperan al último rezagado, consumen memoria, ocupan conexiones y pueden bloquear otros workflows de alta prioridad.

En SchemaBridge, la espera no retiene ningún hilo. A medida que cada rama termina, registra su llegada en el estado duradero del join del merge —una fila de estado del join en PostgreSQL, con las finalizaciones de las ramas entregadas a través de Valkey Streams— y termina de inmediato. El vértice Merge espera sin consumir CPU. Cuando el número de ramas llegadas alcanza el número esperado, el motor despacha el merge y el workflow continúa. Esto es sincronización asíncrona por barrera, y es lo que permite que una lógica de negocio con varias ramas espere minutos u horas sin acaparar recursos.

Cómo tratar las señales tardías: el problema de la señal perdida

Un modo de fallo especialmente desagradable al fusionar estado es la señal perdida (stray signal): una rama, o una entrega duplicada de la finalización de una rama, que informa cuando el sistema ya ha seguido adelante. El servicio no estaba caído; simplemente era lento, o el mensaje se volvió a entregar.

En un script heredado esto es un desastre. La señal tardía llega a tu código e intenta actualizar un estado que ya ha avanzado. Esto puede provocar cobros dobles, registros de base de datos corruptos o que un paso se ejecute dos veces. Tienes que escribir lógica compleja para «ignorar señales de workflows terminados».

SchemaBridge absorbe estas señales con guardas de estado ordinarias, no con mecanismos especiales. Un join se despacha una sola vez: despachar el merge es una transición de estado condicional, por lo que un segundo intento de iniciarlo se rechaza. Un paso que ya ha alcanzado un estado terminal no puede completarse de nuevo, así que una finalización tardía para él se registra como redundante y se descarta. Y cada intento de ejecutar un paso está protegido por una época de claim, de modo que un worker que perdió su claim no puede sobrescribir el resultado del worker que lo relevó. Nada de esto hace que la ejecución sea exactly-once —la entrega sigue siendo at-least-once—, pero una señal perdida no puede hacer avanzar un join dos veces.

El coste de una mala sincronización

Los joins mal gestionados son más que un quebradero de cabeza para los desarrolladores. Pensemos en un sitio de comercio electrónico hipotético que, en cada búsqueda de producto, ejecuta un join de «agregador de precios» sobre 50 proveedores externos.

Sé honesto sobre dónde encaja aquí un motor de workflows. Un MERGE de SchemaBridge no tiene ventana temporal: se dispara cuando han llegado todas las ramas esperadas, no «tras 500 ms con lo que haya vuelto». Si importa un plazo, ponlo dentro de la propia llamada de cada rama (un timeout corto en la petición al proveedor) para que una rama lenta termine —con éxito o no— rápidamente. En un fan-out, el éxito parcial se expresa tolerando un número acotado de ramas fallidas, no mediante un plazo. Una página de búsqueda crítica en latencia suele estar mejor servida por la ruta de petición de tu propia aplicación, dejando al workflow el trabajo en segundo plano más lento, como refrescar cachés.

Comparativa de estrategias de merge: Map-Reduce frente a Flow-Sync

| Característica | Map-Reduce (lotes grandes) | Apache Spark (streaming) | SchemaBridge Flow-Sync |

| :--- | :--- | :--- | :--- |

| Enfoque | Procesamiento offline | Streams casi en tiempo real | Lógica de negocio transaccional |

| Persistencia del estado | Ficheros intermedios | Estado con checkpoints | Estado duradero del join en PostgreSQL |

| Gestión de errores | Reejecutar las tareas fallidas | Checkpoint/reinicio | Fallo por rama, reintento o tolerancia acotada |

| Lógica de join | Shuffle por clave | Joins por ventana temporal | Dependencia basada en grafo |

| Durabilidad | Alta | Alta (con checkpoints) | Alta (sobrevive a reinicios de workers) |

Masterclass de «joins con estado»: ¿qué join has obtenido realmente?

No todos los merges son «esperar a todos», y lo más importante que hay que saber sobre los joins de SchemaBridge es cuál obtienes cuando no eliges.

1. El valor por defecto: gana la primera llegada (WAIT_ONE)

Cuando varias aristas convergen en un vértice ordinario, su estrategia de activación por defecto es WAIT_ONE: el vértice se ejecuta una vez, disparado por la rama que llegue primero, y las llegadas posteriores se descartan. No se cancela nada —las ramas más lentas siguen ejecutándose hasta el final—, pero sus payloads nunca llegan al join. Es exactamente lo correcto para «usa el más rápido de tres proveedores meteorológicos», siempre que aceptes que las otras dos llamadas se siguen produciendo. Es exactamente lo incorrecto si dibujaste un diamante esperando los tres resultados, y no recibirás ningún error que te lo advierta. Es el valor por defecto con más consecuencias de toda la plataforma, así que compruébalo en cada convergencia que dibujes.

2. El merge que espera a todos (barrera)

Para recoger todas las ramas, usa un vértice MERGE o establece la estrategia de activación en WAIT_ALL. El join solo se dispara cuando han llegado todas las ramas esperadas, y tú eliges cómo se combinan los payloads: sobrescribiendo en un único objeto plano, un mapa indexado por rama o un array.

{
  "type": "MERGE",
  "config": {
    "mergeStrategy": "NAMED_MAP"
  }
}

Si alguna rama falla, el join no se dispara y la ejecución falla de forma visible; no hay rollback automático de las ramas que tuvieron éxito, así que las acciones de compensación son algo que modelas explícitamente. Para un fan-out creado por un Spawner, puedes optar por tolerar en su lugar un número acotado de hijos fallidos.

3. El join hambriento: una regla de diseño

Nunca coloques un vértice WAIT_ALL o MERGE aguas abajo de una rama condicional. Si una condición desvía el flujo lejos de una de las entradas, esa entrada nunca llega y el join espera eternamente a una rama que nunca iba a ejecutarse. Si necesitas recoger resultados de caminos que puede que no se ejecuten todos, usa WAIT_ONE y gestiona los datos ausentes en el cuerpo del join, o traslada la condición por completo dentro de una de las ramas.

Checklist experta para el merge distribuido

Para construir una estrategia de merge resiliente, sigue estas heurísticas:

1. Elige el join explícitamente: toda convergencia es o bien de primera llegada (WAIT_ONE) o bien de esperar a todos (MERGE / WAIT_ALL). Decide cuál quieres en lugar de heredar el valor por defecto.

2. Acota cada rama, no el merge: el merge no tiene timeout propio, así que pon plazos en las llamadas dentro de cada rama y asegúrate de que cada rama alcanza un estado terminal.

3. Usa idempotencia en las ramas: la entrega es at-least-once. Asegúrate de que, si una rama se ejecuta dos veces, sus efectos secundarios sean seguros.

4. Mantén las condiciones fuera de las barreras: nunca alimentes un join de esperar a todos desde un camino condicional.

5. Minimiza el tamaño del estado: no arrastres datos innecesarios a través del merge. Trae solo los campos que necesita el siguiente paso para reducir los costes de serialización.

6. Planifica el éxito parcial: no toda lógica de negocio requiere el 100 % de las entradas. Pregunta a tu product manager: «¿cuál es el mínimo de datos viable que necesitamos para continuar?».

Conclusión: el merge es la última frontera de la distribución

El paralelismo sin sincronización gestionada es simplemente caos. Llevar el join a un estado duradero del motor significa que una espera larga no cuesta hilos, que una señal tardía o duplicada no puede disparar un join dos veces y que una rama fallida es visible en lugar de perderse en silencio. Lo que el motor no puede hacer es adivinar qué join querías, así que los valores por defecto y las reglas de diseño anteriores importan tanto como la maquinaria.

No deberías preocuparte por mutex ni latches; deberías centrarte en la lógica que ocurre después de que los datos se hayan vuelto a reunir con éxito.

Véase también: Asegurar el pipeline: Zero-Trust en la capa de eventos, sobre cómo proteger flujos con varias ramas y las credenciales que usan, y Dominar los fan-outs, sobre cómo generar las ramas en primer lugar.

Explorar