Maîtriser les fan-outs : construire des workflows durables et idempotents
SchemaBridge Team · 2025-12-15 · Scalability, Idempotency, Orchestration
Gérer de grands fan-outs sans perdre le fil du travail. Une plongée dans le vertex Spawner, les clés d'idempotence par élément et la livraison at-least-once.
Le défi des grands fan-outs : là où les boucles viennent mourir
Tout développeur a déjà écrit une boucle. Qu'il s'agisse d'une boucle for en Java, d'un .map() en JavaScript ou d'une list comprehension en Python, la logique est la même : prendre une liste d'éléments et faire quelque chose pour chacun. C'est la forme la plus simple de traitement de données. Avec une poignée d'éléments, c'est trivial. Avec quelques centaines, c'est gérable. Mais dès que l'on franchit le seuil de plusieurs milliers, la modeste boucle devient un sérieux handicap pour la fiabilité et la scalabilité de votre application.
Dans le monde des systèmes distribués, la boucle locale est un point de défaillance. Quand on passe d'une poignée d'éléments à plusieurs milliers, la complexité n'augmente pas simplement de façon linéaire : elle se heurte à un mur de complexité. Ce mur est fait des réalités implacables de la gestion mémoire, de la latence réseau et de la défaillance inévitable des machines qui exécutent votre code. Vous cessez de penser « logique » et commencez à lutter contre la « physique ».
Les limites de la boucle locale : pourquoi Promise.all relève de l'avant-passage à l'échelle
Dans une implémentation naïve, vous recevez peut-être un énorme payload JSON — par exemple un gros CSV quotidien de commandes ou un export par lot d'un CRM — et vous enveloppez dans une boucle un appel d'API vers un service en aval. Si vous êtes un développeur JavaScript moderne, vous utiliserez peut-être Promise.all() pour tous les lancer en parallèle. C'est la première erreur du développeur qui ne pense pas encore à l'échelle.
À grande échelle, c'est un désastre pour trois raisons critiques :
1. Épuisement de la mémoire : le crash silencieux
Charger en mémoire un très grand nombre d'objets complexes peut facilement faire planter votre worker. Même une taille modeste par objet s'additionne vite, et les données brutes peuvent gonfler davantage encore dans les runtimes gourmands en mémoire. Le résultat est une erreur « Out of Memory » (OOM) immédiate qui tue le processus avant même que le premier élément soit traité.
2. Timeout d'exécution : le compte à rebours
La plupart des plateformes imposent des limites strictes. Si votre boucle effectue plusieurs milliers d'appels d'API et que chacun ne prend que 100 ms, votre script peut tourner pendant de nombreuses minutes. Même en parallélisant, vous restez contraint par les limites de ressources et le surcoût de ce conteneur unique. La plateforme vous arrêtera avant que le dernier élément soit traité, laissant votre système dans un état indéterminé.
3. Aucune trace de la progression
Quand le processus meurt, la seule trace de ce qui a été fait se trouve dans le système de destination. Personne ne peut dire quels éléments ont été envoyés, lesquels ont réussi et lesquels n'ont jamais été tentés sans un rapprochement forensique.
Place au Spawner : le fan-out sous forme d'exécutions enfants durables
Chez SchemaBridge, le fan-out est un vertex Spawner dédié. Un Spawner n'est pas qu'une boucle : il transforme chaque élément en une unité de travail à part entière que le moteur suit et rapporte individuellement, et que vous pouvez relancer individuellement.
Comment fonctionne réellement un Spawner : la division parallèle
Le Spawner découple la liste de l'exécution des éléments. Chaque élément cesse d'être une itération au sein d'un processus et devient sa propre exécution :
1. Une exécution enfant par élément : Pour chaque élément, le Spawner démarre une exécution enfant du workflow cible. Chaque enfant est persisté dans PostgreSQL avec son propre ID, son propre statut et ses propres logs, et conserve sa filiation jusqu'à l'exécution parente.
2. Clés déterministes par élément : Chaque requête d'enfant porte une clé d'idempotence dérivée de l'exécution parente, du vertex Spawner et de la position de l'élément. Si l'étape du Spawner elle-même est relivrée — par exemple parce que son pod worker redémarre en plein fan-out et qu'un autre worker récupère l'étape —, elle soumet à nouveau la liste depuis le début, et les enfants déjà démarrés sont reconnus grâce à leur clé au lieu d'être créés deux fois.
3. Indépendance du cycle de vie : Si le 501e élément échoue, cela n'empêche pas le 502e d'être dispatché ni de réussir. Vous gagnez la granularité de nombreuses exécutions individuelles au lieu d'un seul lot massif et fragile, et un enfant en échec peut être relancé isolément.
Soyez lucide sur la garantie : la déduplication sur cette clé est une recherche effectuée avant la création de l'enfant, pas une contrainte d'unicité en base de données ; elle réduit donc la fenêtre de doublons au lieu de la fermer. Le moteur est at-least-once, et tout ce qu'un enfant fait vis-à-vis du monde extérieur doit être idempotent côté destination.
L'économie du parallélisme
Faire tourner un worker lourd et mono-thread pendant 15 minutes coûte cher. Vous payez non seulement l'instance à forte mémoire, mais aussi le « temps d'inactivité » pendant lequel votre code attend les réponses de l'API. Vous gaspillez des cycles CPU facturables en attentes d'I/O réseau.
Avec des exécutions enfants par élément, la forme du travail change :
- Exécution parallèle : Les enfants sont pris en charge par les workers qui ont de la capacité, au lieu de tous s'exécuter dans un seul processus de longue durée.
- Rayon d'impact réduit : L'échec d'un enfant n'affecte pas les autres. Dans une boucle traditionnelle, une seule fuite mémoire ou une exception non gérée sur un élément peut faire tomber toute l'exécution.
Nous nous attendrions à ce que cela utilise le calcul plus efficacement qu'un script batch de longue durée qui passe l'essentiel de son temps à attendre des I/O, mais le gain principal porte sur la fiabilité et l'observabilité, pas sur la facture.
Le modèle a des limites, et vous devez les connaître. Un fan-out est vérifié par rapport au quota d'exécutions de votre plan avant le démarrage de tout enfant, car chaque élément est une exécution. Le Spawner garde la liste des éléments en mémoire et soumet les enfants un par un depuis une seule étape : ce n'est donc pas un tuyau infiniment large. Et lorsque le stockage de payloads de la plateforme est sous pression, le Spawner diffère l'ensemble du fan-out — il met l'étape en attente et la relance plus tard depuis un minuteur durable — plutôt que de pousser davantage de données dans un stockage déjà en difficulté.
L'idempotence dans les fan-outs distribués : at-least-once, sous garde-fous
Le principal obstacle des schémas de fan-out est l'idempotence. Si l'étape du Spawner est interrompue puis réexécutée, comment éviter de démarrer deux fois le même enfant ?
SchemaBridge superpose plusieurs garde-fous :
- Clés par élément : La clé déterministe décrite plus haut permet à un Spawner réexécuté de retrouver les enfants qui existent déjà au lieu d'en créer de nouveaux. La recherche renvoie un enfant existant quel que soit son statut : un enfant déjà en échec est donc rattaché, pas réexécuté.
- Garde-fous au niveau de l'étape : Au sein du moteur, une étape déjà terminée n'est pas réexécutée, et la prise en charge d'une étape pour exécution est une transition d'état atomique ; un message en double pour la même étape est donc écarté au lieu d'être exécuté deux fois.
Ce que cela ne vous apporte pas, c'est une exécution exactly-once. Un worker peut planter après qu'un effet de bord s'est produit mais avant que son achèvement soit enregistré, et l'étape s'exécutera alors à nouveau. C'est pourquoi nous décrivons la plateforme comme at-least-once avec de solides garde-fous, et pourquoi la destination de tout appel non idempotent a besoin de sa propre protection.
Scénario illustratif : l'échec d'une grande migration
Imaginez une équipe qui réalise la migration critique d'un très grand lot d'écritures comptables. Elle choisit un script Python classique. Au milieu d'un job de longue durée, le VPN tombe. Le script plante.
La reprise (à la manière coûteuse)
La reprise est pénible : les ingénieurs passent des jours à parcourir la base de destination et à rapprocher manuellement les enregistrements pour déterminer exactement ce qui a été écrit et ce qui ne l'a pas été.
La reprise (à la manière SchemaBridge)
Imaginez maintenant la même migration modélisée comme un Spawner qui déploie une exécution enfant par écriture comptable, et la même coupure de connectivité.
1. Les échecs restent par élément : Les enfants déjà en cours poursuivent leur exécution ; ceux qui n'ont pas pu joindre la destination échouent individuellement, chacun avec sa propre erreur, tandis que le reste du fan-out n'est pas affecté. Si le Spawner lui-même ne parvient pas à soumettre un enfant, l'étape du Spawner échoue de manière visible au lieu de faire semblant d'attendre.
2. Progression visible : La vue d'exécution indique combien d'enfants ont réussi et combien ont échoué, si bien que l'équipe n'aurait pas à reconstituer la progression à partir de la base de destination.
3. Reprise ciblée : Une fois la connectivité rétablie, l'équipe relance individuellement les enfants en échec au lieu de réexécuter toute la migration. Relancer l'étape du Spawner en échec ne les réexécuterait pas : les clés par élément rattachent chaque enfant qui existe déjà, y compris ceux en échec, si bien qu'un retry du Spawner ne crée que les enfants qui n'ont jamais démarré. La reprise deviendrait une action d'opérateur qui se mesure en clics, et non un projet forensique — même si les écritures comptables elles-mêmes doivent toujours être idempotentes, car un enfant relancé peut répéter une écriture qui avait déjà abouti.
Comparaison détaillée : les modèles de passage à l'échelle en conditions réelles
| Fonctionnalité | Boucle naïve (forEach) | SQS/Lambda (fait maison) | Spawner SchemaBridge |
| :--- | :--- | :--- | :--- |
| Gestion de l'état | Locale (volatile) | Manuelle (BD/file) | Native (durable, par enfant) |
| Gestion des erreurs | Un seul try/catch | Retries/DLQ manuels | Isolation des échecs par élément + retry par l'opérateur |
| Visibilité | Fragments de logs | Profondeur de file opaque | Statut par enfant dans la vue d'exécution |
| Logique de jointure | Difficile (un seul thread) | Très difficile (compteurs) | Vertex Merge natif |
| ID idempotent | Aucun | Génération manuelle | Clé déterministe par élément |
Checklist d'expert pour l'orchestration à fort volume
Si vous concevez un fan-out à fort volume, suivez ces règles empiriques :
1. Bornez la concurrence côté destination : Protégez le système en aval avec une limite de concurrence. Dans SchemaBridge, il s'agit d'une limite de concurrence par destination (définie sur la page Rate Limits), qui s'applique à toutes les étapes appelant cette destination, plutôt que d'un réglage sur le Spawner lui-même. Commencez prudemment et élargissez-la en surveillant la santé du système en aval.
2. Considérez l'échec d'un élément comme normal : Assurez-vous que chaque élément peut être relancé indépendamment, et décidez à l'avance si un enfant en échec doit faire échouer tout le fan-out ou être toléré.
3. Surveillez la latence de la « longue traîne » : Repérez la petite fraction d'éléments qui prennent bien plus de temps que les autres. Ce sont généralement vos cas limites les plus complexes ou des cibles de verrous en base de données.
4. Exploitez les ID déterministes : Utilisez les clés par élément du moteur pour vous protéger des redémarrages, et donnez aussi à votre destination une clé métier stable. Ne comptez jamais sur un horodatage pour garantir l'unicité dans un fan-out à forte concurrence.
{
"type": "MERGE",
"config": {
"tolerateChildFailures": ["FAILED"],
"failureTolerance": { "maxFailureRatio": 0.05 },
"failFast": true
}
}
La tolérance aux échecs partiels est optionnelle (opt-in) et se configure sur le merge de callback du fan-out : par défaut, tout enfant en échec fait échouer la jointure, ce qui est le choix sûr lorsque chaque élément compte. Ci-dessus, les enfants en échec sont tolérés jusqu'à 5 % du fan-out, et failFast règle la jointure dès que ce budget est définitivement dépassé, au lieu d'attendre les retardataires.
Conclusion : le passage à l'échelle est un problème d'infrastructure
Maîtriser les fan-outs ne consiste pas à écrire de meilleures boucles, mais à concevoir pour la distribution. En confiant au moteur l'itération, l'état par élément et la suppression des doublons, un échec partiel devient une liste d'éléments précis en échec plutôt qu'un état inconnu — et c'est toute la différence entre un retry et un projet de rapprochement.
Le passage à l'échelle a toujours des limites — quotas, backpressure et nature at-least-once de la livraison distribuée —, mais ces limites sont visibles et bornées, au lieu d'être découvertes en production à 3 heures du matin.
Voir aussi : Le moteur d'idempotence : garantir l'intégrité transactionnelle, sur la manière dont les clés et l'idempotence côté destination s'articulent, et Maîtriser le Merge, pour collecter les résultats d'un fan-out.