Paquets#

Durable se compose d’une bibliothèque centrale, d’une intégration de framework facultative et d’un backend à choisir. Le backend est l’endroit où vit le journal d’une exécution et ce qui planifie son travail ; le journal est la suite d’événements, en ajout seul, qui enregistre chaque étape d’une exécution (voir le glossaire). Installez ce dont vous avez besoin : la bibliothèque seule suffit pour écrire un workflow et le tester unitairement, et les paquets qui s’ajoutent au-dessus changent seulement l’endroit où l’exécution est enregistrée, jamais le code du workflow.

Paquet Apporte Exige
gplanchat/durable workflows, activités, minuteurs, journal d’événements, backend en mémoire psr/cache
gplanchat/durable-bundle câblage Symfony, transports Messenger, panneau du profileur la bibliothèque et Symfony Messenger
gplanchat/durable-bridge-temporal le pilote Temporal, en gRPC la bibliothèque, ext-grpc, un cluster Temporal
gplanchat/durable-bridge-dbal l’exécution durable sur une base SQL la bibliothèque, Doctrine DBAL 3 ou 4, symfony/lock
gplanchat/durable-bridge-illuminate la même chose, par la couche de base de données de Laravel la bibliothèque, illuminate/database 11, 12 ou 13
gplanchat/durable-laravel le câblage Laravel : les ports liés depuis la configuration, le travail sur la file de l’application la bibliothèque, illuminate/support ; le pont Illuminate ou Temporal, selon le backend choisi
gplanchat/durable-magento un module Magento 2.4 / Mage-OS : déclaration, workers, écran d’administration la bibliothèque ; Temporal pour tout ce qui doit survivre à un processus
gplanchat/durable-plugin un tableau de bord Sylius pour les exécutions le bundle, knplabs/knp-menu ; Sylius 2.x pour apparaître dans son menu
gplanchat/durable-filament un tableau de bord des exécutions dans un panneau Filament l’intégration Laravel, Filament 3 ou 4
gplanchat/durable-phpstan l’analyse statique des appels de stub face à leur contrat la bibliothèque, phpstan/phpstan
gplanchat/durable-rector la migration automatisée depuis le SDK PHP de Temporal la bibliothèque, rector/rector

Un workflow est la classe PHP qui décrit les étapes d’une exécution, et une activité est une unité d’effet de bord qu’un workflow appelle, comme un appel HTTP ou une écriture en base.

Les trois ponts sont des alternatives et ne se superposent pas : vous installez Temporal, DBAL ou Illuminate, jamais deux d’entre eux.

Les deux derniers paquets sont des outils de développement et se placent en require-dev :

  • gplanchat/durable-phpstan résout les appels d’activityStub() et de childWorkflowStub() face à l’interface de contrat. Une activité mal nommée ou un mauvais argument apparaît alors comme une erreur d’analyse, au lieu d’un échec de sérialisation à l’exécution. Il vérifie aussi qu’un paramètre #[Activities] et son docblock @param ActivityStub<Contrat> nomment le même contrat.
  • gplanchat/durable-rector migre un projet depuis le SDK PHP officiel de Temporal. Il réécrit les attributs et change le modèle d’exécution, en conservant les noms de type de workflow et d’activité qu’un serveur en cours d’exécution a déjà enregistrés. Il pose un commentaire sur chaque construction qu’il ne peut pas convertir, pour que vous les voyiez avant de commencer. Voir la page de comparaison.

gplanchat/durable, la bibliothèque#

composer config minimum-stability beta
composer config prefer-stable true
composer require gplanchat/durable

La bibliothèque contient le moteur et tout le domaine : WorkflowEnvironment, activités, minuteurs, effets de bord, signaux, requêtes, mises à jour, workflows enfants, journal d’événements, et les objets valeur qui décrivent les options de planification.

Elle a une seule dépendance d’exécution, psr/cache, et le pool de cache lui-même est facultatif : il mémorise la résolution des contrats d’activité, et ActivityContractResolver fonctionne sans. La bibliothèque n’exige aucun framework. Vous pouvez la piloter depuis un simple script PHP, une application Laminas, un outil en ligne de commande ou un test.

Elle inclut un backend en mémoire qui fait tout tourner dans un seul processus. Vos tests unitaires l’utilisent, et il ne demande rien d’autre à installer.

Le backend en mémoire ne garde aucun état d’un processus à l’autre. Utilisez-le pour les tests et l’exploration locale ; un workflow qui doit survivre à un déploiement a besoin d’un autre backend. Voir Backends.


gplanchat/durable-bundle, l’intégration Symfony#

composer config minimum-stability beta
composer config prefer-stable true
composer require gplanchat/durable-bundle

Le bundle prend en charge ce que vous écririez sinon à la main :

  • L’autoconfiguration. Le bundle enregistre chaque classe qui porte #[AsWorkflow] ou #[AsActivityHandler]. Vous ne listez ces classes dans aucun fichier de conteneur, et vous ne les balisez pas. #[AsActivity] nomme le contrat et n’enregistre rien.
  • Le câblage Messenger. Les reprises de workflow et les envois d’activité partent vers les transports que vous nommez dans durable.yaml : un workflow qui se suspend reprend donc par vos files existantes.
  • Deux commandes de console pour lire. debug:durable liste les workflows enregistrés ; avec un type ou une classe de workflow, elle montre les paramètres du point d’entrée et les signaux, requêtes et mises à jour, sous le nom qu’un appelant envoie. durable:execution:diagnose <executionId> affiche ce que le moteur détient d’une exécution : ses métadonnées de workflow, ses liens parent/enfant et son journal d’événements. Le bundle n’ajoute aucune commande de worker ; le worker est le messenger:consume de Messenger sur les transports ci-dessus.
  • Le panneau du profileur. Dans la barre d’outils Symfony, le panneau montre chaque exécution, son journal et la chronologie de ses activités, avec la tentative qui a échoué et la raison.

Toute la configuration tient dans un fichier, documenté clé par clé dans la référence de configuration.


gplanchat/durable-bridge-temporal, le pilote Temporal#

composer config minimum-stability beta
composer config prefer-stable true
composer require gplanchat/durable-bridge-temporal

Le pont parle à un cluster Temporal directement en gRPC. L’arbre de dépendances ne contient ni le SDK PHP officiel de Temporal ni RoadRunner : les définitions protobuf sont embarquées et les workers sont de simples processus PHP.

Par rapport au backend en mémoire, il apporte :

  • des exécutions qui survivent aux redémarrages de processus, aux déploiements et aux plantages ;
  • des politiques de réessai côté serveur : une activité en échec est réessayée même si le worker a disparu ;
  • les planifications cron, les attributs de recherche, et la visibilité entre processus dans l’interface Temporal ;
  • un stockage d’événements en lecture traversante, pour que le profileur montre l’historique d’une vraie exécution.

Il exige ext-grpc et un cluster joignable. En local, une commande démarre un serveur de développement :

temporal server start-dev --namespace durable-test --port 7233

Les planifications cron et les attributs de recherche sont des capacités de Temporal sans équivalent en processus. Les backends à journal ne les exécutent pas. Le cronSchedule d’un workflow enfant y échoue avec UnsupportedByBackendException, de même que son namespace et son taskQueue. Ses attributs de recherche sont écrits au journal et rien n’agit dessus, et les options de démarrage d’un workflow racine n’existent que sur le client Temporal.


gplanchat/durable-bridge-dbal, le backend SQL#

composer config minimum-stability beta
composer config prefer-stable true
composer require gplanchat/durable-bridge-dbal

Le pont fournit l’exécution durable sur une seule base SQL, sans cluster d’orchestration et sans ext-grpc. DUR030 consigne la décision qui le fonde.

Le pont laisse intacts l’interpréteur de rejeu, les ports de workflow et le tampon de commandes. Le rejeu est la façon dont une exécution reprend : le code du workflow tourne à nouveau depuis sa première ligne, et chaque étape enregistrée renvoie son résultat depuis le journal. Le pont rend seulement persistants quatre stockages locaux au processus : le journal d’événements, les métadonnées de workflow, les liens parents des workflows enfants, et le catalogue des exécutions, la liste des exécutions que lit un tableau de bord. Deux classes se partagent la table du catalogue : DbalWorkflowRunProjection y écrit et DbalWorkflowRunCatalog la lit. Le code de workflows et d’activités est octet pour octet celui qui tourne sur Temporal ou en mémoire.

Conservé Abandonné par rapport à Temporal
Classes de workflow, activités, WorkflowEnvironment Les files de tâches distribuées ; les reprises passent par Symfony Messenger
Signaux, requêtes, mises à jour La planification côté serveur ; les minuteurs passent par le DelayStamp de Messenger
La sémantique d’annulation et de compensation La sérialisation des tâches côté serveur, remplacée par un verrou applicatif
Le déterminisme du rejeu et le journal d’événements La rétention d’historique, l’API de visibilité, l’interface Temporal

Choisissez-le quand vous avez besoin de durabilité sans opérer de cluster. Il demande une base que vous sauvegardez déjà et aucune extension à compiler. Le pont ne livre aucune migration : DurableSchema crée les tables à la première écriture, et bin/console durable:setup les crée d’avance (voir Backend DBAL).


gplanchat/durable-bridge-illuminate, le backend Laravel#

composer config minimum-stability beta
composer config prefer-stable true
composer require gplanchat/durable gplanchat/durable-bridge-illuminate
php artisan migrate

Ce pont fournit les mêmes quatre stockages que le pont DBAL, avec les mêmes compromis face à Temporal : le tableau ci-dessus s’applique mot pour mot. Une seule classe, IlluminateWorkflowRunCatalog, écrit et lit le catalogue des exécutions. La connexion change. Ces stockages utilisent Illuminate\Database\Connection et son constructeur de requêtes, sans Eloquent.

Donnez aux stockages leur propre connexion dans config/database.php, distincte de la connexion par défaut de l’application (DUR054). Sur une connexion partagée, les transactions propres à Durable s’imbriquent dans celles de l’application : un rollback métier efface des événements du journal, et une prise de main reste invisible aux autres workers tant que le code métier n’a pas validé. Pour traiter une activité qui écrit puis meurt, rendez l’activité idempotente. Ne partagez jamais une transaction avec le code métier à cette fin.

Les cinq tables sont livrées en migrations, chargées directement depuis le paquet : migrate suffit. Chaque stockage a sa table, et le journal d’événements écrit aussi dans durable_execution_heads, un compteur par exécution qui empêche une reprise dépassée d’écrire (DUR053). Pour les modifier, publiez-les avec vendor:publish --tag=durable-migrations ; à partir de là, vous maintenez les copies publiées. La commande copie tout le répertoire Migrations/ du paquet, cinq fichiers, dans database/migrations. Gardez les noms des fichiers publiés. Laravel indexe les migrations par leur nom de base et donne la priorité à database/migrations quand deux noms coïncident, ce qui fait de votre copie celle qui s’exécute. Si vous renommez un fichier, Laravel exécute à la fois le fichier du paquet et votre copie. Seule 0001_01_01_000000_create_durable_tables.php échoue à la seconde exécution : ses appels à Schema::create n’ont aucune garde et s’arrêtent sur une table qui existe déjà. Les quatre autres migrations vérifient d’abord hasColumn, hasIndex ou hasTable et ne modifient rien la seconde fois.

ResumeLock couvre ce qu’aucun choix de stockage ne fournit. Il vit dans gplanchat/durable-laravel, qui s’en sert sur tous les backends. Quand deux workers reprennent la même exécution, tous deux la rejouent, tous deux traitent les commandes qu’elle produit comme nouvelles, et ces commandes partent en double. Le journal ne l’empêche pas, car il enregistre tout ce qu’il reçoit, doublons compris. ResumeLock prend une fermeture : un job en file, une commande artisan ou un worker écrit à la main peuvent tous s’en servir.

Ce pont fournit seulement le stockage. Il ne lie aucun port et ne livre ni commande de worker ni job ; DurableIlluminateServiceProvider enregistre seulement l’emplacement des migrations. gplanchat/durable-laravel, décrit dans la section suivante, lie les stockages. Si vous installez le pont seul, vous câblez les stockages vous-même, comme le fait une application sans framework.


gplanchat/durable-laravel, l’intégration Laravel#

composer config minimum-stability beta
composer config prefer-stable true
composer require gplanchat/durable-laravel gplanchat/durable-bridge-illuminate
php artisan migrate
php artisan vendor:publish --tag=durable-config

Le paquet suggère les deux ponts et n’en exige aucun : vous installez celui du backend choisi, gplanchat/durable-bridge-illuminate pour une base SQL, comme ci-dessus, ou gplanchat/durable-bridge-temporal pour un cluster Temporal. En mémoire, aucun des deux. Avec backend sur illuminate, la valeur par défaut, et le pont absent, l’enregistrement échoue avec la commande à lancer.

L’auto-discovery des paquets enregistre le provider. À partir d’un seul config/durable.php publié, le provider lie les quatre ports de stockage, les jobs d’activité et de reprise, et le verrou par exécution.

Une seule valeur backend lie tous les ports. Un journal sur un backend avec un catalogue d’exécutions sur un autre est une panne : backend prend donc une seule valeur. Une valeur que ce paquet ne sert pas fait échouer l’enregistrement, avec une erreur qui la nomme et nomme les trois backends que le paquet sert : illuminate, memory et temporal.

Vous déclarez les workflows dans la configuration. Laravel n’a pas d’équivalent de l’autoconfiguration par attribut de Symfony : la clé workflows nomme donc les classes. Les nommer coûte 0,14 ms, mesuré, et ce coût ne grandit pas avec l’application. Un scan par réflexion coûte 15 ms à mille classes et les charge toutes dans chaque processus pour en trouver cinq. Pour la même raison, il n’y a pas de durable:cache : config:cache met déjà en cache le fichier qu’il dupliquerait.

Le travail passe par la file que l’application draine déjà, avec php artisan queue:work pour seul worker. Activités et reprises sont des jobs ; un minuteur est un job de déclenchement différé qui utilise le délai natif de la file.

Comparaison avec durable-workflow/workflow#

durable-workflow/workflow, anciennement laravel-workflow/laravel-workflow, fournit l’exécution durable sur les files de Laravel, avec son propre stockage. Il s’inspire explicitement de Temporal et d’Azure Durable Functions et compte plus de mille étoiles. Depuis la 2.0, les workflows s’y écrivent en méthodes linéaires portées par des Fibers, et il tourne au choix intégré à votre application, sur son propre serveur autonome ou sur son Cloud géré, avec des SDK PHP, Python et Rust. Il livre une interface de suivi, Waterline. Il fait bien son travail ; si vous cherchez un moteur pensé d’abord pour Laravel, choisissez-le.

gplanchat/durable-laravel propose un autre choix de backend. Le même code de workflow tourne contre un cluster Temporal (Temporal Cloud et Nexus compris, avec un historique que l’interface de Temporal lit) ou contre une base SQL, sans cluster à opérer. Un parc mixte Symfony / Sylius / Laravel partage aussi un seul moteur : une classe de workflow écrite pour gplanchat/durable-bundle tourne ici sans modification. durable-workflow/workflow ne propose ni l’un ni l’autre.

Démarrer une exécution#

WorkflowResumeDispatcher::dispatchNewWorkflowRun() démarre une exécution sur chaque backend :

  • sur illuminate, il met en file la première reprise pour queue:work ;
  • sur temporal, il démarre le workflow sur le cluster, qui livre tout ce qui suit ;
  • sur memory, il met l’exécution en file dans le processus appelant. Menez-la dans le même processus avec app(InProcessWorkflowResumeDispatcher::class)->drain() après l’envoi. Dans un processus console (tests, commandes, workers de file), Artisan::call('durable:drain') fait de même ; la commande n’est pas enregistrée pendant une requête HTTP. Le drainage rend la main une fois l’exécution terminée, ou quand elle attend un signal ou une échéance au-delà du budget de dix secondes. Le journal de ce backend vit dans le processus : rien hors du processus ne peut faire avancer l’exécution, et un php artisan durable:drain lancé à part démarre avec une file vide et ne mène rien.

Servir des opérations Nexus#

Une opération Nexus est une opération servie par un autre service, avec son propre contrat, qu’un workflow appelle comme il appelle une activité (voir le glossaire). En servir une, c’est répondre à un appel venu d’un autre espace de noms, et seul le cluster route ces appels. La clé nexus.handlers nomme les gestionnaires et les contrats qu’ils servent :

'nexus' => ['handlers' => [App\Nexus\BillingHandler::class => App\Contracts\BillingService::class]],

Un workflow remplit les opérations qu’aucun gestionnaire ne sert. Il porte #[FulfilsNexusOperation], et il suffit qu’il figure dans la liste workflows ci-dessus. Un contrat se sépare en deux interfaces parce que PHP ne permet pas d’exprimer une implémentation partielle ; le registre recolle les deux moitiés.

Une déclaration Nexus sous un backend qui ne route pas fait échouer l’enregistrement, avant le premier appel, avec une erreur qui nomme le backend. Appeler une opération Nexus ne demande aucune déclaration ici : c’est le workflow qui fait l’appel, et c’est le cas le plus courant.

php artisan durable:nexus-worker draine les opérations que le cluster route vers cette application.

Trois réglages rejetés#

Réglage Rejeté Raison
lock.store: null toujours il accorde tous les verrous, dans tous les déploiements
lock.store: array sous illuminate une reprise tourne dans un worker séparé de celui qui l’a dispatchée, donc deux verrous array ne se voient jamais : quinze sections critiques chevauchées sur vingt, mesurées
la connexion de file sync sous illuminate elle exécute les jobs sur place : une reprise qui en dispatche une autre récurse jusqu’à épuiser la pile

array reste accepté sous memory. C’est le cache de test par défaut de Laravel, et un test n’a besoin d’exclusion mutuelle qu’à l’intérieur d’un seul processus.

Deux comportements qui ressemblent à des bugs#

Le driver sqlite ne peut pas héberger plus d’un worker. Avec quatre workers qui dépilent la table jobs, la file renvoie SQLSTATE[HY000]: General error: 5 database is locked, et trois sur quatre meurent à leur premier job, WAL activé et busy_timeout à 60 s. Passez à MySQL, PostgreSQL ou Redis pour la file dès que vous lancez un second worker.

Le job d’un worker tué reste réservé jusqu’à retry_after, 90 secondes par défaut. Un worker lancé avec --stop-when-empty dans cette fenêtre trouve une file vide et sort sans rien faire, ce qui ressemble trait pour trait à une reprise en échec. La reprise n’a pas encore reçu le job. Un worker supervisé survit à la fenêtre, prend le job, et l’exécution se termine.

Pas dans ce paquet#

Temporal est bien pris en charge. backend: 'temporal' place le journal et le catalogue d’exécutions dans le cluster, et deux workers drainent ce que la file de l’application ne peut pas porter : php artisan durable:temporal-worker draine les tâches de workflow, et php artisan durable:temporal-worker --role=activity les tâches d’activité.

gplanchat/durable-bridge-temporal est seulement suggéré. Il installe huit paquets, dont cinq composants Symfony qu’une application Laravel ne charge jamais, pour quelque 36 Mo. Une application qui ne choisit pas ce backend ne les installe jamais, et celle qui le choisit reçoit une erreur qui nomme le paquet à installer. Scinder le pont, dont la partie couplée à Symfony fait huit fichiers sur 774, retirerait ce poids ; c’est une modification distincte.

Aucun tableau de bord. gplanchat/durable-filament exige ce paquet. Ce paquet le suggère dans composer.json et ne l’exige ni ne le détecte jamais.


gplanchat/durable-plugin, le tableau de bord Sylius#

composer config minimum-stability beta
composer config prefer-stable true
composer require gplanchat/durable-plugin

Le plugin affiche le tableau de bord dans l’administration Sylius : une entrée dans le menu d’administration, la liste des exécutions sur des cartes Tabler avec pagination par curseur, et le détail à côté. Les panneaux, le regroupement et les libellés viennent de gplanchat/durable lui-même : une exécution se lit donc de la même façon ici et sur l’écran Magento. Ce paquet fournit l’habillage Sylius autour d’eux.

Les libellés affichent l’ActivityType.name lisible et ne retombent sur les identifiants techniques que si aucun nom n’est disponible.

Le plugin lit les exécutions et n’exécute rien. Il exige gplanchat/durable-bundle, qui câble le catalogue d’exécutions qu’il lit : la commande ci-dessus constitue donc toute l’installation.

Les données vivantes viennent du backend installé, quel qu’il soit. Le plugin n’exige aucun pont : gplanchat/durable suggère le backend, une fois, pour toutes les intégrations. Sans backend, le plugin s’installe quand même, la route et l’entrée de menu fonctionnent, et le tableau de bord affiche son état dégradé au lieu d’exécutions vivantes.

gplanchat/durable-filament, le tableau de bord Filament#

composer config minimum-stability beta
composer config prefer-stable true
composer require gplanchat/durable-filament
// app/Providers/Filament/AdminPanelProvider.php
use Gplanchat\Durable\Filament\DurableFilamentPlugin;

return $panel
    // ...
    ->plugin(DurableFilamentPlugin::make());

Le plugin affiche le tableau de bord dans un panneau Filament 3 ou 4 : une entrée Exécutions Durable dans la navigation du panneau, la liste des exécutions avec pagination par curseur et les filtres par nom et par identifiant d’exécution que le backend peut appliquer, et une page par exécution avec son état, ce qu’elle attend, ses opérations Nexus et son historique. Il est disponible en anglais et en français.

Le plugin lit les exécutions et n’exécute rien. Il exige gplanchat/durable-laravel et lit le catalogue d’exécutions que ce paquet lie pour son backend : en mémoire, Illuminate ou Temporal. Rien dans le plugin ne nomme un backend, et rien dans gplanchat/durable-laravel ne nomme Filament.

La page d’une exécution liste ses opérations Nexus quand le catalogue les rapporte, et seul celui de Temporal les rapporte : un journal ne peut pas contenir d’opération Nexus, donc en mémoire et sur Illuminate la section n’apparaît jamais.

gplanchat/durable-magento, l’intégration Magento#

composer config minimum-stability beta
composer config prefer-stable true
composer require gplanchat/durable-magento

Un module Magento 2.4 / Mage-OS, listé comme Gplanchat_DurableModule dans bin/magento module:status. Il déclare les classes de workflow et d’activité au moteur, assemble le moteur pour un processus Magento, livre les workers en commandes bin/magento, et ajoute un écran d’administration en lecture seule sous System > Durable processes > Process history.

L’écran utilise l’habillage de Magento : une grille standard (pagination, signets, choix des colonnes, export, et un filtre d’état multi-select dont les options viennent de l’énumération des états elle-même), avec l’état du backend et les compteurs par issue au-dessus. Le contenu de l’écran ne vient ni de Magento ni de ce paquet ; voir le tableau de bord, que chaque hôte affiche dans son propre habillage.

Le conteneur de Magento n’a pas d’équivalent de l’autoconfiguration par tag de Symfony : vous déclarez donc les classes explicitement, dans deux tableaux de di.xml :

<type name="Gplanchat\DurableModule\Runtime\RuntimeFactory">
    <arguments>
        <argument name="workflowClasses" xsi:type="array">
            <item name="place_order" xsi:type="string">Acme\Shop\Workflow\PlaceOrder</item>
        </argument>
        <argument name="activityHandlers" xsi:type="array">
            <item name="order" xsi:type="object">Acme\Shop\Activity\OrderActivities</item>
        </argument>
    </arguments>
</type>

Vous ne déclarez pas le contrat. La fabrique lit les interfaces de chaque gestionnaire et garde celles qui portent #[AsActivityMethod], ce qui fait une déclaration de moins à écrire de travers et laisse les noms d’activité à ceux des attributs.

Trois autres arguments de la même fabrique bornent une exécution, et di.xml est le seul endroit où les régler :

<argument name="maxActivityRetries" xsi:type="number">3</argument>
<argument name="budgetSeconds" xsi:type="number">30</argument>
<argument name="maxContinuations" xsi:type="number">10</argument>
  • maxActivityRetries est le plafond de tentatives des activités que MagentoRuntime::run() exécute dans le processus appelant quand aucun DSN n’est configuré, l’équivalent du max_activity_retries du bundle Symfony. La valeur par défaut, 0, ne fixe aucun plafond. Les workers Temporal ne le lisent jamais : là, le cluster relance d’après la RetryLimit propre à l’activité.
  • budgetSeconds borne MagentoRuntime::run(). Sans DSN, l’appel mène un workflow à son terme dans le processus appelant ; avec un DSN, il attend aussi longtemps le résultat du cluster. Au-delà du budget, l’appel lève WorkflowStuckException au lieu d’attendre encore. La valeur par défaut est 10. Dans le processus, le budget existe à cause du plafond de tentatives : sans plafond, une activité qui échoue sans cesse occuperait ce processus pour toujours.
  • maxContinuations est, sans DSN, le nombre de continue-as-new (une exécution qui referme son journal et passe la main à une exécution neuve ; voir le glossaire) que suit l’exécution dans le processus avant de lever ContinuationCapReachedException. La valeur par défaut est 10. Avec un DSN, le cluster mène la chaîne.

Les workers et workflowClient() n’en lisent aucun.

Magento prend en charge trois backends, et Composer l’impose. Magento atteint la mémoire, Temporal et son propre backend de base de données, et le module déclare un conflict sur les deux ponts SQL, car Magento\Framework\App\ResourceConnection n’est ni une connexion Doctrine DBAL ni celle d’Illuminate. app/etc/env.php choisit le backend : resource/durable pour le backend de base de données (voir Backends), un DSN pour Temporal, ni l’un ni l’autre pour la mémoire ; déclarer les deux échoue avec BackendSelectionException. Un DSN choisit Temporal :

'durable' => [
    'temporal' => ['dsn' => 'temporal://temporal:7233?namespace=default&tls=0'],
],

Sans ce DSN, le journal vit dans le processus qui l’écrit et disparaît quand ce processus se termine. C’est acceptable pour une commande console, inadapté à tout le reste.

MagentoRuntime::run() suit le même choix. Sans DSN, il exécute le workflow dans le processus appelant. Avec un DSN, il démarre le workflow sur le cluster et attend son résultat, que produisent les workers ci-dessous. L’attente dure environ budgetSeconds et se termine par WorkflowStuckException.

Avec un DSN, un workflow qui échoue arrive chez l’appelant avec la classe que lève le backend en mémoire. L’exception du workflow est reconstruite par new $class($message, $code) : elle porte le message et le code enregistrés, sans exception précédente ni autre propriété. Il faut pour cela que sa classe se charge dans le processus appelant et que son constructeur accepte (message, code) ; sinon, l’appelant reçoit une WorkflowFailedException, dont le message commence par Workflow "<execution id>" failed:. Un échec d’activité arrive sous la forme d’une DurableWorkflowAlgorithmFailureException, avec l’exception de l’activité comme exception précédente. Seul le DSN ajoute deux issues : WorkflowTimedOutException quand un délai fixé sur le cluster expire, et WorkflowTerminatedException quand quelqu’un termine l’exécution. Un workflow qui attend un signal attend tout le budget au lieu d’échouer aussitôt.

Le résultat revient décodé du JSON : un objet que le workflow renvoie arrive sous forme de tableau.

Pour démarrer un workflow depuis un observateur, appelez RuntimeFactory::resumeDispatcher()->dispatchNewWorkflowRun(). Avec un DSN, il démarre le workflow sur le cluster et rend la main aussitôt. Sans DSN, il exécute le workflow dans le processus appelant, dans la limite de budgetSeconds, et la requête l’attend : il bloque comme run(). Un workflow qui échoue ne lève pas d’exception depuis l’appel, comme sur le cluster ; l’échec part dans le logger que la fabrique détient. Un workflow non déclaré, lui, lève une exception. Pour démarrer uniquement sur le cluster, appelez workflowClient()->startAsync().

Les workers sont des commandes bin/magento, pas des consommateurs de file. Supervisez-les comme n’importe quel processus long :

bin/magento durable:worker --role=journal   --time-limit=3600
bin/magento durable:worker --role=activity  --time-limit=3600

Chaque processus sert un rôle sur une file. Les rôles journal et activité utilisent deux files Temporal distinctes, et vous réglez leur parallélisme séparément. Rien ne passe par le MessageQueue de Magento : sur Temporal, une activité est une commande Temporal et une reprise une tâche de workflow, donc un topic Magento ne ferait qu’ajouter une seconde file à superviser.

Avec resource/durable dans env.php, la même commande vide les files de la base au lieu d’interroger Temporal. Sans --role, un processus sert les files de reprise, de minuteur et d’activité ; --role=journal sert les reprises et les minuteurs, --role=activity sert les activités. Ce backend n’a pas de --role=nexus, et la commande le refuse. Un message est acquitté une fois traité. Quand un autre worker tient l’exécution, quand une reprise arrive avant le résultat de son activité, ou quand MySQL répond par un délai d’attente de verrou ou un interblocage, le message reste dans la file et est livré de nouveau peu après. SIGTERM et SIGINT arrêtent le worker entre deux messages.

Une boutique qui sert des opérations Nexus liste ses gestionnaires dans un tableau de plus de la même fabrique, nexusHandlers : un objet par gestionnaire, dont le #[AsNexusServiceHandler] nomme le contrat servi. Une opération pour laquelle le gestionnaire n’a pas de méthode vient d’une entrée de workflowClasses qui porte #[FulfilsNexusOperation]. Un troisième processus, bin/magento durable:worker --role=nexus, les sert sur la nexus_task_queue du DSN, qui vaut par défaut la file des tâches de workflow. Sans DSN, ce worker échoue avec A Nexus worker needs a cluster.

Un worker absent se manifeste différemment selon son rôle. Sans --role=journal, rien n’avance : les exécutions démarrent, leur historique se remplit, et aucun processus ne répond à leurs tâches de workflow. Sans --role=activity, l’exécution semble fonctionner, ce qui rend le problème plus difficile à voir : elle avance jusqu’à sa première activité et s’y arrête, la commande débitée et le stock intact, et c’est le client qui vous l’apprend.

Les bornes --time-limit et --max-tasks servent au superviseur : elles terminent le processus pour que le superviseur puisse le relancer. Les nouvelles tentatives relèvent du cluster, qui planifie les tentatives d’une activité qu’un worker écoute ou non. Une exécution dont l’activité « a échoué après 3 tentatives » en quelques secondes signale un worker absent.

Les réglages de file de Magento ne s’appliquent pas à Durable. retry_inprogress_after, les tâches cron messagequeue_* et queue_lock ne portent rien de Durable, puisque rien de Durable ne passe par MessageQueue. Réglez-les pour vos propres consommateurs.

Démarrez les exécutions sur le cluster, hors de la requête qui les déclenche. Avec un DSN, un observateur sur sales_order_place_after confie l’exécution à Temporal et rend la main. Un démarrage peut encore lever une exception (un workflow non déclaré) : interceptez-la, car une exception qui sort de l’observateur interrompt le flux propre de la boutique.

public function execute(Observer $observer): void
{
    $incrementId = $observer->getEvent()->getData('order')->getIncrementId();

    try {
        $this->runtimeFactory->resumeDispatcher()->dispatchNewWorkflowRun(
            ExecutionId::fromString('order-' . $incrementId),
            PlaceOrder::class,
            ['orderId' => $incrementId],
        );
    } catch (\Throwable $exception) {
        $this->logger->error('Le workflow n\'a pas démarré : ' . $exception->getMessage());
    }
}

Le même observateur fonctionne sans cluster, avec une différence : le workflow s’exécute dans la requête, qui l’attend, pendant budgetSeconds au plus (10 par défaut). Un workflow qui attend un signal ou un long minuteur retient la requête pendant tout le budget. Le journal en mémoire disparaît avec la requête : une exécution non terminée à ce moment est perdue. C’est acceptable en développement ; en production, configurez le DSN.


Qu’est-ce que j’installe ?#

Chaque commande ci-dessous est celle que le sélecteur de la page d’accueil vous donne, écrite en toutes lettres.

Le sélecteur lit son état dans l’URL : un lien peut donc ouvrir la page avec une situation déjà choisie, par exemple dans un ticket, un README ou une réponse de support :

https://durable.rocks/fr/?fw=magento&be=temporal#install

fw est le framework (none, symfony, laravel, sylius, apiplatform, magento), be l’endroit où vit l’état (memory, temporal, dbal, illuminate), et dist la base sous une distribution (none, symfony, laravel). Chaque axe est facultatif. Le sélecteur ignore une valeur qu’il ne peut pas appliquer (un framework qui n’est pas publié, un backend que l’appariement interdit) au lieu de la forcer : un vieux lien retombe sur le choix par défaut au lieu d’afficher une combinaison qui n’existe pas. Choisir dans la page réécrit la barre d’adresse : le lien à partager est donc celui que vous avez déjà dans la barre d’adresse.

Chaque commande du tableau suppose que le projet accepte d’abord la ligne bêta :

composer config minimum-stability beta
composer config prefer-stable true
Votre situation Commande
Découverte, ou tests unitaires seulement composer require gplanchat/durable
Sans framework, une base SQL composer require gplanchat/durable gplanchat/durable-bridge-dbal
Sans framework, un cluster Temporal composer require gplanchat/durable gplanchat/durable-bridge-temporal
Symfony, tests seulement composer require gplanchat/durable-bundle
Symfony, une base SQL composer require gplanchat/durable-bundle gplanchat/durable-bridge-dbal
Symfony, un cluster Temporal composer require gplanchat/durable-bundle gplanchat/durable-bridge-temporal
Sylius, tests seulement composer require gplanchat/durable-plugin
Sylius, une base SQL composer require gplanchat/durable-plugin gplanchat/durable-bridge-dbal
Sylius, un cluster Temporal composer require gplanchat/durable-plugin gplanchat/durable-bridge-temporal
Laravel, une base SQL composer require gplanchat/durable-laravel gplanchat/durable-bridge-illuminate
Laravel, cluster Temporal composer require gplanchat/durable-laravel gplanchat/durable-bridge-temporal
Magento, un cluster Temporal composer require gplanchat/durable-magento gplanchat/durable-bridge-temporal

Chaque ligne ne nomme que l’intégration : le bundle tire la bibliothèque, et le plugin tire le bundle. Sans framework, vous nommez la bibliothèque vous-même, et vous câblez aussi les workers vous-même.

Les lignes Laravel nomment l’intégration et le pont du backend, car gplanchat/durable-laravel n’exige aucun des deux ponts. C’est un service provider qui lie les quatre ports de stockage, avec des workflows déclarés dans config/durable.php et le travail sur la file que l’application draine déjà ; la section ci-dessus décrit ce qu’il fait à votre place. Pour câbler les stockages vous-même, sans l’intégration, installez la bibliothèque et le pont.


Le même comportement sur tous les backends#

Tous les backends font tourner le même pilote à fibres et le même chemin d’exécution des activités. Un workflow que vous avez testé en mémoire se comporte de la même façon contre DBAL ou contre Temporal, y compris pour le décompte des réessais, la classification des échecs, l’annulation et la compensation.

Certaines capacités n’ont pas d’équivalent sur un backend. Nexus y échoue avec un message explicite ; le namespace, le taskQueue et le cronSchedule d’un workflow enfant échouent avec UnsupportedByBackendException, et ses attributs de recherche sont enregistrés sans être exécutés. Backends liste les différences.


Monorepo et publications#

Durable se développe dans un seul dépôt, gplanchat/durable-dev. Une scission publie chaque paquet dans son propre dépôt en lecture seule, si bien qu’un composer require tire un petit paquet plutôt que tout l’arbre.