Maîtriser le merge : coordonner l’état dans des mondes parallèles

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

Synchroniser des branches parallèles sans race conditions : joins durables, le problème de la longue traîne et les valeurs par défaut qui décident du join obtenu.

Le paradoxe du parallélisme : liberté contre synchronisation

Dans notre quête de performance, nous avons adopté le parallélisme. Nous distribuons nos tâches, lançons des workers asynchrones et dispersons nos données sur de nombreux nœuds. Nous gagnons en débit, mais nous le payons cher en complexité. Le plus difficile dans les systèmes distribués n’est pas de lancer des choses en même temps, mais de les réunir ensuite.

Imaginez un parcours complexe de traitement de commande : vous lancez simultanément une tâche « Débiter la carte », une tâche « Vérifier le stock » et une tâche « Calculer la livraison ». Pour passer à l’étape suivante — imprimer la facture —, il vous faut les résultats des trois. C’est un vertex Merge, aussi appelé join distribué ou synchroniseur à barrière.

Dans un environnement mono-thread, c’est simple : il suffit d’attendre le retour de trois appels de fonction. Mais dans un moteur distribué, ces tâches s’exécutent sur des machines différentes et peuvent se terminer à quelques secondes, voire à des heures d’intervalle. L’une peut échouer pendant que les autres réussissent. C’est le paradoxe du parallélisme : plus vous parallélisez votre travail pour gagner en vitesse, plus il devient difficile de coordonner le résultat final.

L’évolution historique de la synchronisation par barrière

Pour comprendre pourquoi fusionner de l’état est si difficile, il faut se tourner vers l’histoire du calcul haute performance (HPC). Dans les années 1970 et 1980, les informaticiens ont développé le concept de barrière. Une barrière est un point de synchronisation où chaque thread d’un processus parallèle doit s’arrêter et attendre que tous les autres threads soient arrivés. Ce n’est qu’une fois le compte atteint que le processus peut avancer.

Dans les systèmes monolithiques, on l’implémentait avec des spin-locks ou des mutex en mémoire partagée. Le CPU de la machine gérait l’état de la barrière à une vitesse quasi infinie. Mais en passant aux systèmes distribués, nous avons perdu la « mémoire partagée ». Il n’y avait plus de CPU unique pour jouer l’arbitre.

Les années 2000 ont vu l’essor de Map-Reduce (l’article fondateur de Google). Map-Reduce offrait une échelle massive pour le traitement parallèle, mais il était conçu pour des « charges de travail batch ». On mappait ses données, puis venait une phase « Reduce » qui agrégeait tous les résultats. Une tâche de map en échec était simplement réexécutée, mais une tâche lente retardait la phase de reduce ; la conception de Google combattait ces traînards avec des tâches de secours, qui réexécutaient de manière spéculative les dernières tâches en cours vers la fin du job. Cela nous amène au défi opérationnel le plus important de la fusion d’état : le problème de la longue traîne.

Le problème de la longue traîne : la branche la plus lente l’emporte

Dans un join distribué de 1 000 éléments, votre temps d’exécution total n’est pas déterminé par la vitesse moyenne de vos workers, mais par la latence du worker le plus lent. Si 999 éléments se terminent en 10 ms mais qu’un élément prend 10 secondes à cause d’un verrou en base de données ou d’un aléa réseau, tout votre workflow attend 10 secondes.

C’est le problème de la longue traîne. Dans une implémentation naïve, il entraîne une accumulation massive de consommation de ressources. Pendant que 999 threads attendent le dernier retardataire, ils consomment de la mémoire, occupent des connexions et peuvent bloquer d’autres workflows prioritaires.

Chez SchemaBridge, l’attente ne retient aucun thread. À mesure que chaque branche se termine, elle enregistre son arrivée dans l’état durable du join du merge — une ligne d’état de join dans PostgreSQL, les fins de branches étant acheminées via Valkey Streams — puis se termine immédiatement. Le vertex Merge attend sans consommer de CPU. Lorsque le nombre de branches arrivées atteint le nombre attendu, le moteur dispatche le merge et le workflow continue. C’est la synchronisation asynchrone par barrière, et c’est ce qui permet à une logique métier à plusieurs branches d’attendre des minutes ou des heures sans monopoliser de ressources.

Gérer les signaux tardifs : le problème du signal égaré

Un mode de défaillance particulièrement pernicieux lors de la fusion d’état est le signal égaré : une branche, ou une livraison en double de la fin d’une branche, qui se manifeste alors que le système est déjà passé à autre chose. Le service n’était pas mort ; il était simplement lent, ou le message a été relivré.

Dans un script legacy, c’est une catastrophe. Le signal tardif arrive dans votre code et tente de mettre à jour un état qui a déjà évolué. Cela peut entraîner des doubles débits, des enregistrements corrompus en base ou une étape exécutée deux fois. Vous devez écrire une logique complexe pour « ignorer les signaux des workflows terminés ».

SchemaBridge absorbe ces signaux avec des gardes d’état ordinaires plutôt qu’avec une mécanique spéciale. Un join n’est dispatché qu’une seule fois : le dispatch du merge est une transition d’état conditionnelle, si bien qu’une seconde tentative de le démarrer est refusée. Une étape qui a déjà atteint un statut terminal ne peut pas être complétée à nouveau ; une fin tardive la concernant est donc enregistrée comme redondante et ignorée. Et chaque tentative d’exécution d’une étape est protégée par une époque de claim, de sorte qu’un worker qui a perdu son claim ne peut pas écraser le résultat du worker qui a pris le relais. Rien de tout cela ne rend l’exécution exactly-once — la livraison reste at-least-once —, mais un signal égaré ne peut pas faire avancer un join deux fois.

Le coût d’une mauvaise synchronisation

Des joins mal gérés sont plus qu’un casse-tête pour les développeurs. Prenons un site e-commerce hypothétique qui exécute, à chaque recherche de produit, un join « agrégateur de prix » sur 50 fournisseurs tiers.

Soyez honnête sur la place d’un moteur de workflow ici. Un MERGE SchemaBridge n’a pas de fenêtre temporelle : il se déclenche lorsque toutes les branches attendues sont arrivées, pas « après 500 ms avec ce qui est revenu ». Si une échéance compte, placez-la dans l’appel de chaque branche (un timeout court sur la requête au fournisseur) afin qu’une branche lente se termine — avec succès ou non — rapidement. Pour un fan-out, le succès partiel s’exprime en tolérant un nombre borné de branches en échec, et non par une échéance. Une page de recherche sensible à la latence est généralement mieux servie par le chemin de requête de votre propre application, le workflow se chargeant du travail d’arrière-plan plus lent, comme le rafraîchissement des caches.

Comparer les stratégies de merge : Map-Reduce contre Flow-Sync

| Caractéristique | Map-Reduce (gros batch) | Apache Spark (streaming) | SchemaBridge Flow-Sync |

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

| Cible | Traitement hors ligne | Flux quasi temps réel | Logique métier transactionnelle |

| Persistance de l’état | Fichiers intermédiaires | État avec checkpoints | État de join durable dans PostgreSQL |

| Gestion des erreurs | Réexécuter les tâches en échec | Checkpoint/redémarrage | Échec par branche, retry ou tolérance bornée |

| Logique de join | Shuffle par clé | Joins par fenêtre temporelle | Dépendance fondée sur le graphe |

| Durabilité | Élevée | Élevée (avec checkpoints) | Élevée (survit aux redémarrages de workers) |

Masterclass « join à état » : quel join avez-vous réellement obtenu ?

Tous les merges ne sont pas des « attendre tout le monde », et la chose la plus importante à savoir sur les joins SchemaBridge, c’est celui que vous obtenez quand vous ne choisissez pas.

1. Le défaut : la première arrivée l’emporte (WAIT_ONE)

Lorsque plusieurs arêtes convergent vers un vertex ordinaire, sa stratégie d’activation est WAIT_ONE par défaut : le vertex s’exécute une fois, déclenché par la première branche qui arrive, et les arrivées suivantes sont ignorées. Rien n’est annulé — les branches plus lentes s’exécutent quand même jusqu’au bout —, mais leurs payloads n’atteignent jamais le join. C’est exactement ce qu’il faut pour « utiliser le plus rapide de trois fournisseurs météo », à condition d’accepter que les deux autres appels aient quand même lieu. C’est exactement ce qu’il ne faut pas si vous avez dessiné un losange en attendant les trois résultats, et aucune erreur ne vous le signalera. C’est le défaut le plus lourd de conséquences de toute la plateforme : vérifiez-le donc sur chaque convergence que vous dessinez.

2. Le merge qui attend tout le monde (barrière)

Pour collecter chaque branche, utilisez un vertex MERGE ou réglez la stratégie d’activation sur WAIT_ALL. Le join ne se déclenche que lorsque toutes les branches attendues sont arrivées, et vous choisissez comment les payloads sont combinés : écrasement dans un seul objet plat, une map indexée par branche ou un tableau.

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

Si une branche échoue, le join ne se déclenche pas et l’exécution échoue de manière visible ; il n’y a pas de rollback automatique des branches qui ont réussi, les actions de compensation sont donc à modéliser explicitement. Pour un fan-out créé par un Spawner, vous pouvez à la place choisir de tolérer un nombre borné d’enfants en échec.

3. Le join affamé : une règle de conception

Ne placez jamais un vertex WAIT_ALL ou MERGE en aval d’une branche conditionnelle. Si une condition contourne l’une des entrées, cette entrée n’arrive jamais, et le join attend éternellement une branche qui n’allait jamais s’exécuter. Si vous devez collecter les résultats de chemins qui ne s’exécuteront peut-être pas tous, utilisez WAIT_ONE et gérez les données manquantes dans le corps du join, ou déplacez entièrement la condition à l’intérieur d’une des branches.

Checklist d’expert pour le merge distribué

Pour construire une stratégie de merge résiliente, suivez ces heuristiques :

1. Choisissez le join explicitement : chaque convergence est soit de type première arrivée (WAIT_ONE), soit de type attendre tout le monde (MERGE / WAIT_ALL). Décidez de ce que vous voulez au lieu d’hériter du défaut.

2. Bornez chaque branche, pas le merge : le merge n’a pas de timeout propre ; placez donc des échéances sur les appels à l’intérieur de chaque branche et assurez-vous que chaque branche atteint un état terminal.

3. Rendez les branches idempotentes : la livraison est at-least-once. Assurez-vous que, si une branche s’exécute deux fois, ses effets de bord restent sûrs.

4. Gardez les conditions hors des barrières : n’alimentez jamais un join qui attend tout le monde depuis un chemin conditionnel.

5. Minimisez la taille de l’état : ne faites pas transiter de données inutiles par le merge. N’apportez que les champs dont l’étape suivante a besoin, pour réduire les coûts de sérialisation.

6. Prévoyez le succès partiel : toute logique métier n’exige pas 100 % des entrées. Demandez à votre product manager : « quel est le minimum de données viable pour continuer ? »

Conclusion : le merge est l’ultime frontière de la distribution

Le parallélisme sans synchronisation maîtrisée n’est que chaos. Déplacer le join dans un état durable du moteur signifie qu’une longue attente ne coûte aucun thread, qu’un signal tardif ou dupliqué ne peut pas déclencher un join deux fois, et qu’une branche en échec est visible au lieu d’être perdue en silence. Ce que le moteur ne peut pas faire, c’est deviner quel join vous vouliez : les valeurs par défaut et les règles de conception ci-dessus comptent donc autant que la mécanique.

Vous ne devriez pas vous soucier de mutex ou de latches ; vous devriez vous concentrer sur la logique qui intervient après que les données ont été réunies avec succès.

Voir aussi : Sécuriser le pipeline : le Zero-Trust dans la couche d’événements, sur la protection des flux à plusieurs branches et des identifiants qu’ils utilisent, et Maîtriser les fan-outs, sur la création des branches en premier lieu.

Explorer