Command Palette

Search for a command to run...

Pourquoi Rust pour un moteur de workflows

🇬🇧EN

IronFlow est un moteur de workflows où les workflows sont du code Rust impératif, pas du YAML. FSM typée, 10 providers IA, parallélisme natif avec Tokio.

18 min de lecture
rustworkflow-engineironflowtokioopen-sourcearchitecture
Schéma du moteur de workflows IronFlow avec FSM typée et code Rust impératif

La plupart des moteurs de workflows fonctionnent de la même manière : on définit un graphe d’étapes dans un fichier YAML ou un DSL, un moteur l’interprète, et on prie pour que l’exécution soit celle qu’on a imaginée. J’ai utilisé cette approche pendant des années avec n8n, Airflow, et même Temporal dans une version antérieure, avant de me heurter aux limites du déclaratif quand la logique métier se complexifie.

J’ai construit IronFlow pour résoudre ce problème. C’est un moteur de workflows où les workflows sont du code Rust impératif : pas de YAML, pas de DSL. Le moteur persiste chaque étape, suit les coûts, et expose le tout via une API REST.

Le problème avec le déclaratif

Un fichier YAML de workflow, ça fonctionne bien pour les cas simples : A puis B puis C. Le problème arrive quand on a besoin de :

  • Conditions imbriquées : si l’étape 2 échoue et que l’étape 1 a produit un certain flag, sauter l’étape 3 mais exécuter l’étape 4 avec des paramètres différents
  • Parallélisme conditionnel : fan-out sur N items, mais certains items ont besoin d’un traitement différent
  • Gestion d’erreur granulaire : retry avec backoff sur certaines étapes, fail-fast sur d’autres, logs structurés pour le debug

Dans un YAML, ces scénarios produisent des arbres de conditions qui deviennent illisibles. On finit par écrire du code dans les “hooks” ou les “scripts” embarqués dans le YAML, ce qui revient à coder dans un langage de template au lieu d’un vrai langage.

Temporal a résolu ce problème en proposant des workflows en code (Go, Java, TypeScript, Python). Leur approche est la bonne. Mais Temporal impose une infrastructure lourde : un cluster avec Cassandra ou MySQL, un service frontend, un service history, un service matching. Pour un projet qui a besoin de lancer des agents IA et des commandes shell de manière orchestrée, c’est surdimensionné.

Pourquoi Rust et pas Go ou Node

Le choix du langage pour un moteur de workflows n’est pas neutre. Le moteur est un composant d’infrastructure qui tourne en continu, gère la concurrence, et manipule des machines à états. Voici ce qui a motivé le choix de Rust.

Le système de types pour les machines à états

Le coeur d’IronFlow est une FSM (finite state machine) qui gère le cycle de vie de chaque run. Un run passe par des états précis - Pending, Running, AwaitingApproval, Retrying, Completed, Failed, Cancelled - et les transitions entre ces états sont contraintes.

En Rust, cette contrainte est exprimée dans le système de types. La FSM rejette les transitions invalides à la compilation, pas à l’exécution :

pub enum RunEvent {
    PickedUp,
    AllStepsCompleted,
    StepFailed,
    StepFailedRetryable,
    RetryStarted,
    MaxRetriesExceeded,
    CancelRequested,
    ApprovalRequested,
    Approved,
    Rejected,
}

Chaque événement ne peut être appliqué que depuis certains états. Approved n’est valide que depuis AwaitingApproval. PickedUp n’est valide que depuis Pending. La table de transitions est explicite dans le code :

PendingRunningCompletedAwaitingApprovalRetryingFailedPickedUpAllStepsCompletedApprovalRequestedRetryableStepFailedRejectedMaxRetriesApprovedRetryStarted

En Go, cette même logique utiliserait des switch sur des string ou des int. L’erreur d’une transition invalide n’apparaitrait qu’au runtime. En Node, on n’aurait même pas de garantie sur les types des événements.

Tokio et le parallélisme sans compromis

Un moteur de workflows doit gérer de la concurrence partout : plusieurs runs en parallèle, des étapes concurrentes dans un même run, des appels HTTP en attente, des workers qui interrogent l’API. Tokio fournit tout ça avec des performances proches du métal.

IronFlow utilise un modèle API + workers. L’API possède la persistance et ne fait jamais d’exécution. Les workers interrogent l’API pour les runs en attente, les exécutent localement, et streament les étapes et les logs. Scaler, c’est lancer plus de workers.

Concrètement, un workflow peut fan-out sur des étapes parallèles avec ctx.parallel() :

let checks = ctx
    .parallel(
        vec![
            ("test", StepConfig::Shell(ShellConfig::new("cargo test"))),
            ("lint", StepConfig::Shell(ShellConfig::new("cargo clippy"))),
            ("audit", StepConfig::Shell(ShellConfig::new("cargo audit"))),
        ],
        true, // fail-fast : arrête tout si une étape échoue
    )
    .await?;

Chaque étape s’exécute dans sa propre task Tokio. Le ? propage les erreurs naturellement. Pas de callback hell, pas de promesses à chaîner.

Un binaire unique, zéro dépendances

Go produit aussi un binaire statique, et c’est un argument récurrent en sa faveur. Mais Rust va plus loin avec lto = true, strip = true et codegen-units = 1 dans le profil release : le binaire est plus compact et démarre plus vite.

Pour IronFlow, ça signifie un déploiement trivial : un seul fichier à copier sur le serveur. Pas de runtime Node, pas de JVM, pas de Python avec ses virtualenvs. Le worker tourne avec quelques Mo de RAM, même sous charge.

Ce que Rust rend naturel dans IronFlow

Des workflows en code impératif

Un workflow IronFlow est une implémentation du trait WorkflowHandler. On reçoit un WorkflowContext et on chaîne des opérations :

struct Deploy;

impl WorkflowHandler for Deploy {
    fn name(&self) -> &str {
        "deploy"
    }

    fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
        Box::pin(async move {
            ctx.shell("build", ShellConfig::new("cargo build --release"))
                .await?;

            let checks = ctx
                .parallel(
                    vec![
                        ("test", StepConfig::Shell(ShellConfig::new("cargo test"))),
                        ("lint", StepConfig::Shell(ShellConfig::new("cargo clippy"))),
                        ("audit", StepConfig::Shell(ShellConfig::new("cargo audit"))),
                    ],
                    true,
                )
                .await?;

            if checks.is_empty() {
                return Ok(());
            }

            ctx.approval("gate", ApprovalConfig::new("Ship to production?"))
                .await?;

            ctx.shell("deploy", ShellConfig::new("./deploy.sh")).await?;
            Ok(())
        })
    }
}

Le flow de contrôle est du Rust standard. Si les tests échouent, le ? propage l’erreur et le run passe en Failed. L’approval gate suspend le run jusqu’à une action humaine. Pas besoin d’apprendre un DSL.

Des providers IA interchangeables

IronFlow supporte 10 providers IA, tous derrière le même trait AgentProvider. Un workflow écrit pour Claude tourne sur n’importe quel autre provider sans modification :

let router = ProviderRouter::new(claude)
    .route(ProviderMatcher::ModelPrefix("nvidia/".into()), nvidia);

let a = Agent::new().prompt("Review").model(Model::SONNET).run(&router).await?;

let b = Agent::new().prompt("Review").model("nvidia/deepseek-v4-flash").run(&router).await?;

Le ProviderRouter dispatche sur le nom du modèle. Un workflow peut mixer les vendors dans la même exécution. Les providers incluent Claude Code (local), SSH, Docker, Kubernetes (éphémère et persistant), Anthropic API, OpenAI, Gemini, Mistral et NVIDIA NIM.

En Go, ce pattern de routage par trait serait implémenté via des interfaces - similaire en surface, mais sans les garanties de lifetime que Rust fournit. En Node, on utiliserait du duck typing et on découvrirait les erreurs en production.

Architecture en 12 crates

Le workspace est découpé en 12 crates, chacune avec une responsabilité précise :

InterfacesCLISDKMCPDashboardAPIironflow-api (REST + SSE + auth)Moteurironflow-engineworkerruntimeCoreironflow-coreironflow-storeironflow-authCLI/SDK/MCP -> API (persistance) -> Worker (exécution) -> Providers

Le système de features de Cargo permet de n’inclure que ce dont on a besoin. ironflow-core fonctionne comme une bibliothèque standalone sans serveur ni base de données. Les providers comme SSH, Docker ou Kubernetes sont derrière des feature flags (transport-ssh, transport-docker, transport-k8s).

Comparaison honnête avec les alternatives

IronFlowTemporalWindmilln8n
DéfinitionCode Rust impératifCode (Go/Java/TS/Python)Scripts (Python/TS/Go) + UIGUI + JSON
InfrastructureAPI + workers (Postgres)Cluster (Cassandra/MySQL)Serveur (Postgres)Serveur (SQLite/Postgres)
Agents IA natifs10 providers, budget intégréNonNonVia plugins
DéploiementBinaire uniqueCluster multi-servicesDockerDocker
LangageRustGo (serveur) + multi-SDKRust (serveur)Node.js

Temporal est le choix évident pour les équipes qui ont besoin de durable execution à grande échelle et qui acceptent la complexité opérationnelle. Temporal a d’ailleurs choisi Rust pour leur Core SDK, citant la “fearless concurrency” et le fait que “si ça compile, ça marche probablement”.

Windmill a choisi Rust pour son backend et affiche des performances 10 à 13 fois supérieures à Airflow grâce à la combinaison PostgreSQL + Rust. C’est la même stack qu’IronFlow.

n8n est parfait pour le no-code et l’automatisation rapide, mais sa base Node.js et son modèle GUI limitent les cas d’usage d’infrastructure.

IronFlow se positionne entre Temporal (trop lourd pour des workflows d’agents IA) et les outils no-code (trop limités pour de la logique complexe). Le pari est simple : si le workflow est assez complexe pour avoir besoin de conditions, de boucles et de gestion d’erreur granulaire, il devrait être du code. Et si c’est du code, autant utiliser un langage qui garantit à la compilation que les états sont corrects.

Ce que j’aurais fait différemment

Rust n’est pas sans compromis. Le temps de compilation d’un workspace de 12 crates est significatif - plusieurs minutes pour un build release. Et le pool de développeurs Rust est plus petit que celui de Go ou TypeScript.

Si IronFlow était un outil interne d’entreprise avec une équipe de 10 personnes, Go serait probablement un meilleur choix. Mais pour un projet open source où la correction du moteur est critique et où les performances comptent, Rust est le bon compromis.

Le code est open source

IronFlow est publié sous licence MIT sur GitLab (miroir GitHub). Toutes les crates sont sur crates.io. Le projet fait partie d’un écosystème d’outils que je développe autour de l’automatisation et de Claude Code, avec MCP RTK (proxy de filtrage MCP) et les skills Claude Code que j’utilise au quotidien. Le setup complet qui lie tous ces outils est détaillé dans un article précédent. La page du projet contient les liens d’installation et la documentation. Pour un plongeon dans l’architecture interne de l’API (entities, store traits, handlers Axum), voir Architecture en couches d’une API REST Rust avec Axum et SQLx.