Dominar los fan-outs: cómo construir workflows duraderos e idempotentes
SchemaBridge Team · 2025-12-15 · Scalability, Idempotency, Orchestration
Gestionar fan-outs grandes sin perder el control del trabajo. Un análisis a fondo del vértice Spawner, las claves de idempotencia por elemento y la entrega at-least-once.
El reto de los fan-outs grandes: donde los bucles van a morir
Todo desarrollador ha escrito un bucle. Ya sea un bucle for en Java, un .map() en JavaScript o una list comprehension en Python, la lógica es la misma: tomar una lista de elementos y hacer algo con cada uno. Es la forma más simple de procesamiento de datos. Con un puñado de elementos, es trivial. Con unos cientos, es manejable. Pero al cruzar el umbral de muchos miles, el humilde bucle se convierte en un serio riesgo para la fiabilidad y la escalabilidad de tu aplicación.
En el mundo de los sistemas distribuidos, el bucle local es un punto de fallo. Cuando pasas de un puñado de elementos a muchos miles, la complejidad no solo crece de forma lineal: choca contra un muro de complejidad. Ese muro está hecho de las duras realidades de la gestión de memoria, la latencia de red y el fallo inevitable de las máquinas que ejecutan tu código. Dejas de pensar en «lógica» y empiezas a luchar contra la «física».
Los límites del bucle local: por qué Promise.all es un patrón previo a la escala
En una implementación ingenua, podrías recibir un payload JSON enorme —por ejemplo, un CSV diario grande de pedidos o una exportación por lotes de un CRM— y envolver en un bucle una llamada a la API de un servicio aguas abajo. Si eres un desarrollador moderno de JavaScript, quizá uses Promise.all() para lanzarlas todas en paralelo. Este es el primer error del desarrollador que aún no piensa a escala.
A escala, esto es un desastre por tres razones críticas:
1. Agotamiento de memoria: el fallo silencioso
Cargar en memoria un número muy grande de objetos complejos puede tumbar fácilmente tu worker. Incluso un tamaño modesto por objeto se acumula rápido, y los datos en bruto pueden inflarse aún más en runtimes que consumen mucha memoria. El resultado es un error inmediato de «Out of Memory» (OOM) que mata el proceso antes de que se procese siquiera el primer elemento.
2. Timeout de ejecución: el reloj corre
La mayoría de las plataformas tienen límites estrictos. Si tu bucle realiza muchos miles de llamadas a la API y cada una tarda solo 100 ms, tu script puede ejecutarse durante muchos minutos. Aunque lo paralelices, sigues atado a los límites de recursos y a la sobrecarga de ese único contenedor. La plataforma te terminará antes de que se procese el último elemento, dejando tu sistema en un estado indeterminado.
3. Sin registro del progreso
Cuando el proceso muere, el único registro de lo que se hizo está en el sistema de destino. Nadie puede decir qué elementos se enviaron, cuáles tuvieron éxito y cuáles nunca se intentaron sin una conciliación forense.
Entra el Spawner: fan-out como ejecuciones hijas duraderas
En SchemaBridge, el fan-out es un vértice Spawner dedicado. Un Spawner no es solo un bucle: convierte cada elemento en una unidad de trabajo de primera clase que el motor rastrea y reporta de forma individual, y que puedes reintentar por separado.
Cómo funciona realmente un Spawner: la división en paralelo
El Spawner desacopla la lista de la ejecución de los elementos. Cada elemento deja de ser una iteración dentro de un proceso y se convierte en su propia ejecución:
1. Una ejecución hija por elemento: Para cada elemento, el Spawner inicia una ejecución hija del workflow de destino. Cada hija se persiste en PostgreSQL con su propio ID, su propio estado y sus propios logs, y conserva su linaje hasta la ejecución padre.
2. Claves deterministas por elemento: Cada solicitud de hija lleva una clave de idempotencia derivada de la ejecución padre, el vértice Spawner y la posición del elemento. Si el propio paso del Spawner se vuelve a entregar —por ejemplo, porque su pod de worker se reinicia a mitad del fan-out y otro worker reclama el paso—, vuelve a enviar la lista desde el principio, y las hijas que ya se habían iniciado se reconocen por su clave en lugar de crearse dos veces.
3. Independencia del ciclo de vida: Si el elemento 501 falla, eso no impide que el 502 se despache ni que tenga éxito. Obtienes la granularidad de muchas ejecuciones individuales en lugar de un único lote enorme y frágil, y una hija fallida puede reintentarse por sí sola.
Sé realista con la garantía: la deduplicación por esa clave es una consulta previa a la creación de la hija, no una restricción de unicidad en la base de datos, así que estrecha la ventana de duplicados en lugar de cerrarla. El motor es at-least-once, y todo lo que una hija haga hacia el mundo exterior debería ser idempotente en el destino.
La economía del paralelismo
Ejecutar un worker pesado y de un solo hilo durante 15 minutos es caro. No solo pagas por la instancia con mucha memoria, sino también por el «tiempo ocioso» mientras tu código espera respuestas de la API. Desperdicias ciclos de CPU facturables en esperas de I/O de red.
Con ejecuciones hijas por elemento, la forma del trabajo cambia:
- Ejecución en paralelo: Las hijas las recogen los workers que tengan capacidad, en lugar de ejecutarse todas dentro de un único proceso de larga duración.
- Menor radio de impacto: Un fallo en una hija no afecta a las demás. En un bucle tradicional, una sola fuga de memoria o una excepción no controlada en un elemento puede tumbar toda la ejecución.
Esperaríamos que esto use el cómputo de forma más eficiente que un script por lotes de larga duración que pasa la mayor parte del tiempo esperando I/O, pero la ganancia más importante es la fiabilidad y la observabilidad, no la factura.
El modelo tiene límites, y deberías conocerlos. Un fan-out se comprueba contra la cuota de ejecuciones de tu plan antes de iniciar cualquier hija, porque cada elemento es una ejecución. El Spawner mantiene la lista de elementos en memoria y envía las hijas de una en una desde un único paso, así que no es una tubería infinitamente ancha. Y cuando el almacén de payloads de la plataforma está bajo presión, el Spawner aplaza todo el fan-out —aparcando el paso y relanzándolo más tarde desde un temporizador duradero— en lugar de empujar más datos a un almacén que ya tiene dificultades.
Idempotencia en fan-outs distribuidos: at-least-once, con salvaguardas
El mayor obstáculo de los patrones de fan-out es la idempotencia. Si el paso del Spawner se interrumpe y se vuelve a ejecutar, ¿cómo evitamos iniciar la misma hija dos veces?
SchemaBridge combina varias salvaguardas:
- Claves por elemento: La clave determinista descrita arriba permite que un Spawner reejecutado encuentre las hijas que ya existen en lugar de crear otras nuevas. La búsqueda devuelve una hija existente sea cual sea su estado, así que una hija que ya falló se vuelve a enlazar, no se vuelve a ejecutar.
- Salvaguardas a nivel de paso: Dentro del motor, un paso que ya se ha completado no se vuelve a ejecutar, y reclamar un paso para ejecutarlo es una transición de estado atómica, de modo que un mensaje duplicado para el mismo paso se descarta en lugar de ejecutarse dos veces.
Lo que esto no te da es ejecución exactly-once. Un worker puede caerse después de que se haya producido un efecto secundario pero antes de que se registre su finalización, y entonces el paso se ejecutará de nuevo. Por eso describimos la plataforma como at-least-once con salvaguardas sólidas, y por eso el destino de cualquier llamada no idempotente necesita su propia protección.
Escenario ilustrativo: el fallo de una gran migración
Imagina un equipo que realiza una migración crítica de un lote muy grande de asientos contables. Deciden usar un script tradicional de Python. A mitad de un trabajo de larga duración, se cae la VPN. El script falla.
La recuperación (por las malas)
La recuperación es dolorosa: los ingenieros pasan días revisando la base de datos de destino y conciliando registros a mano para averiguar exactamente qué se escribió y qué no.
La recuperación (al estilo SchemaBridge)
Ahora imagina la misma migración modelada como un Spawner que despliega una ejecución hija por asiento contable, y la misma caída de conectividad.
1. Los fallos se quedan por elemento: Las hijas que ya estaban en ejecución continúan; las que no pudieron alcanzar el destino fallan de forma individual, cada una con su propio error, mientras el resto del fan-out no se ve afectado. Si el propio Spawner no puede enviar una hija, el paso del Spawner falla de forma visible en lugar de fingir que está esperando.
2. Progreso visible: La vista de ejecución muestra cuántas hijas tuvieron éxito y cuántas fallaron, de modo que el equipo no tendría que reconstruir el progreso a partir de la base de datos de destino.
3. Recuperación dirigida: Una vez restablecida la conectividad, el equipo reintenta las hijas fallidas una por una en lugar de volver a ejecutar toda la migración. Reintentar el paso del Spawner fallido no las volvería a ejecutar: las claves por elemento vuelven a enlazar cada hija que ya existe, incluidas las fallidas, de modo que un reintento del Spawner solo crea las hijas que nunca llegaron a iniciarse. La recuperación pasaría a ser una acción del operador medida en clics, no un proyecto forense, aunque las escrituras contables en sí siguen necesitando ser idempotentes, porque una hija reintentada puede repetir una escritura que ya se había aplicado.
Comparativa detallada: modelos de escalado en el mundo real
| Característica | Bucle ingenuo (forEach) | SQS/Lambda (a mano) | Spawner de SchemaBridge |
| :--- | :--- | :--- | :--- |
| Gestión del estado | Local (volátil) | Manual (BD/cola) | Nativa (duradera, por hija) |
| Gestión de errores | Un único try/catch | Reintentos/DLQ manuales | Aislamiento de fallos por elemento + reintento del operador |
| Visibilidad | Fragmentos de logs | Profundidad de cola opaca | Estado por hija en la vista de ejecución |
| Lógica de unión | Difícil (un solo hilo) | Muy difícil (contadores) | Vértice Merge nativo |
| ID idempotente | Ninguno | Generación manual | Clave determinista por elemento |
Checklist experta para la orquestación de alto volumen
Si estás diseñando un fan-out de alto volumen, sigue estas reglas prácticas:
1. Limita la concurrencia en el destino: Protege el sistema aguas abajo con un límite de concurrencia. En SchemaBridge es un límite de concurrencia por destino (configurado en la página Rate Limits), que se aplica a todos los pasos que llaman a ese destino, en lugar de un ajuste en el propio Spawner. Empieza de forma conservadora y amplíalo mientras observas la salud del sistema aguas abajo.
2. Asume que el fallo de un elemento es normal: Asegúrate de que cada elemento pueda reintentarse de forma independiente, y decide de antemano si una hija fallida debe hacer fallar todo el fan-out o tolerarse.
3. Vigila la latencia de la «cola larga»: Busca la pequeña fracción de elementos que tardan mucho más que el resto. Suelen ser tus casos límite más complejos u objetivos de bloqueos en la base de datos.
4. Aprovecha los IDs deterministas: Usa las claves por elemento del motor para protegerte frente a reinicios, y dale también a tu destino una clave de negocio estable. Nunca confíes en una marca de tiempo para la unicidad en un fan-out con alta concurrencia.
{
"type": "MERGE",
"config": {
"tolerateChildFailures": ["FAILED"],
"failureTolerance": { "maxFailureRatio": 0.05 },
"failFast": true
}
}
La tolerancia a fallos parciales es opcional (opt-in) y se configura en el merge de callback del fan-out: por defecto, cualquier hija fallida hace fallar la unión, que es la opción segura cuando cada elemento importa. Arriba, las hijas fallidas se toleran hasta un 5 % del fan-out, y failFast resuelve la unión en cuanto ese margen se supera de forma definitiva, en lugar de esperar a las rezagadas.
Conclusión: escalar es un problema de infraestructura
Dominar los fan-outs no consiste en escribir mejores bucles, sino en diseñar para la distribución. Al trasladar la iteración, el estado por elemento y la supresión de duplicados al motor, un fallo parcial se convierte en una lista de elementos fallidos concretos en lugar de un estado desconocido, y esa es la diferencia entre un reintento y un proyecto de conciliación.
Escalar sigue teniendo límites —cuotas, backpressure y la naturaleza at-least-once de la entrega distribuida—, pero esos límites son visibles y acotados, en lugar de descubrirse en producción a las 3 de la madrugada.
Véase también: El motor de idempotencia: garantizar la integridad transaccional, sobre cómo encajan las claves y la idempotencia en el destino, y Dominar el Merge, sobre cómo recoger los resultados de un fan-out.