Concepts#
Cette page pose le vocabulaire et le modèle de Durable. Lisez-la avant les guides pratiques. Le glossaire définit chaque terme en un paragraphe.
L’exécution durable#
L’exécution durable, c’est un processus long (quelques secondes, quelques heures ou quelques jours) qui survit aux redémarrages, aux plantages et aux déploiements.
Si vous avez écrit du async/await en JavaScript, partez de là. Une fonction JavaScript qui fait
await doit être async, et ses appelants aussi : la couleur remonte la pile d’appels (Bob
Nystrom, What Color Is Your Function?). Dans un workflow Durable, $env->await() suspend
l’exécution avec une fibre PHP, dans le moteur, si bien qu’une méthode qui attend reste une
méthode ordinaire, avec son propre type de retour. La
comparaison avec le SDK PHP de Temporal
détaille ce problème de coloration.
Ce que JavaScript ne fait pas, c’est conserver l’attente quand le processus s’arrête. Le moteur enregistre chaque décision (résultat d’activité, minuteur échu, signal reçu) dans un historique. Après un redémarrage, il rejoue cet historique pour restaurer l’état exact où le programme se trouvait : la méthode s’exécute de nouveau, et chaque étape déjà enregistrée renvoie son résultat. L’analogie avec JavaScript s’arrête là. Une méthode rejouée doit prendre les mêmes décisions à chaque passage, d’où la règle de déterminisme.
Dans votre code, cela se lit comme du PHP séquentiel ordinaire : vous faites await sur une
activité, vous recevez le résultat, vous continuez. Le moteur prend en charge la tolérance aux
pannes.
Workflow#
Un workflow est de la pure logique d’orchestration. Il :
- planifie des activités (le travail qui produit des effets de bord) et attend leurs résultats ;
- pose des minuteurs (attendre une durée, ou attendre une date) ;
- réagit aux signaux (messages externes à sens unique) et aux mises à jour (messages avec réponse) ;
- expose des requêtes en lecture seule pour inspecter son état sans le modifier ;
- lance des workflows enfants pour les sous-processus.
Une fonction de workflow doit être déterministe : à historique identique, la ré-exécuter doit produire la même suite de commandes. C’est ce qui rend le rejeu possible.
Ce qui n’a pas sa place dans un workflow :
- appels HTTP, requêtes en base, nombres aléatoires, horodatages : tout cela est non déterministe ;
- accès au système de fichiers, lecture de variables d’environnement ;
- toute E/S qui donnerait un résultat différent au rejeu.
Tout cela appartient aux activités.
Activité#
Une activité est l’unité de travail non déterministe, celle qui peut échouer. Les activités :
- font des E/S : appels HTTP, écritures en base, envois de courriels, etc. ;
- utilisent l’injection de dépendances (dépôts, clients HTTP, journaux) ;
- sont réessayées automatiquement en cas d’échec, selon leurs
ActivityOptions; - voient leur résultat enregistré une fois pour toutes ; au rejeu, ce résultat est repris de l’historique sans que l’activité soit ré-exécutée.
Un workflow parle aux activités à travers un ActivityInvoker (le stub typé que vous obtenez par WorkflowEnvironment::activityStub()). Appeler une méthode du stub renvoie un Awaitable ; await() suspend le workflow jusqu’à ce que l’activité se termine.
Historique d’événements et rejeu#
Le moteur consigne chaque événement significatif dans un historique (aussi appelé journal) :
ExecutionStarted
└─ ActivityScheduled(name: "charge-order", attempt: 1)
└─ ActivityCompleted(name: "charge-order", result: "ok")
└─ ExecutionCompleted(result: "done")Quand le processus du workflow redémarre, ou quand Temporal planifie une nouvelle tâche de workflow, le moteur rejoue la fonction du workflow contre cet historique :
Rejeu, étape 1 : await activity("charge-order")
→ l'historique porte ActivityCompleted pour cette étape → renvoie « ok » immédiatement (aucun appel HTTP réel)
Rejeu, étape 2 : return "done"
→ l'historique porte ExecutionCompleted → le workflow est terminéParce que le résultat est dans l’historique, l’implémentation de l’activité n’est pas rappelée pendant le rejeu. La fonction reprend là où elle s’était arrêtée.
Les types d’événements#
| Événement | Quand il est enregistré |
|---|---|
ExecutionStarted |
l’orchestrateur a accepté le workflow |
ActivityScheduled |
le workflow a planifié une activité |
ActivityCompleted |
l’activité a rendu un résultat |
ActivityFailed |
l’activité a levé une exception non rattrapée |
TimerStarted |
le workflow a posé un minuteur |
TimerFired |
le minuteur est échu |
SignalReceived |
un signal externe a été remis au workflow |
UpdateAccepted |
une mise à jour transactionnelle a été acceptée |
ChildWorkflowStarted |
un workflow enfant a été lancé |
ChildWorkflowCompleted |
un workflow enfant s’est terminé |
ChildWorkflowDeadline |
un workflow enfant a été planifié avec un timeout d’exécution : l’instant avant lequel il doit s’être terminé |
ExecutionCompleted |
le workflow a rendu un résultat |
WorkflowExecutionFailed |
le workflow a levé une exception non rattrapée |
await et les awaitables#
WorkflowEnvironment::await() est la primitive unique pour suspendre le workflow. Elle prend un Awaitable, un jeton léger qui tient la place d’un résultat futur, et bloque, conceptuellement, jusqu’à ce que ce résultat soit disponible.
Sous le capot, Durable s’appuie sur les fibres de PHP pour suspendre l’exécution sans bloquer le fil d’exécution système. La fibre reprend quand l’orchestrateur livre le résultat attendu, dans une tâche de workflow ultérieure.
La fibre s'exécute → atteint await(activité) → rien dans l'historique → la fibre se suspend
↓
Temporal planifie une nouvelle tâche avec ActivityCompleted
↓
La fibre reprend depuis await → reçoit le résultat → continueLes awaitables se composent :
// En séquence
$a = $env->await($activities->stepA());
$b = $env->await($activities->stepB($a));
// En parallèle (les deux démarrent d'un coup)
[$a, $b] = $env->await($env->all(
$activities->stepA(),
$activities->stepB(),
));
// À la course (le premier gagne)
$winner = $env->await($env->any(
$activities->fastPath(),
$activities->slowPath(),
));
// Au quorum (assez, c'est assez)
$quotes = $env->await($env->some(3, ...$providers));Signaux, requêtes et mises à jour#
Le signal#
Un signal est un message externe à sens unique, remis à un workflow en cours. Un signal n’a pas de valeur de retour.
Le workflow déclare un gestionnaire pour les signaux qu’il accepte, et le moteur l’appelle quand il en arrive un. Le gestionnaire modifie l’état du workflow ; le corps du workflow repart quand cet état satisfait une condition :
requête HTTP → signal(« approve ») → le gestionnaire s'exécute → la condition tient → le workflow repartenum OrderSignal: string
{
case Approve = 'approve';
case Cancel = 'cancel';
}
#[AsWorkflow('Order')]
final class OrderWorkflow
{
/** @var list<array<string, mixed>> */
private array $approvals = [];
public function __construct(private readonly WorkflowEnvironment $environment) {}
#[AsSignalMethod(OrderSignal::Approve)]
public function onApprove(array $payload): void
{
$this->approvals[] = $payload;
}
#[AsWorkflowMethod]
public function run(): string
{
$this->environment->await(fn(): bool => [] !== $this->approvals);
return 'approved by ' . ($this->approvals[0]['by'] ?? '?');
}
}Un workflow écrit comme un appelable enregistre le même gestionnaire de façon impérative, exactement comme il le ferait d’un gestionnaire de requête :
$env->onSignal(OrderSignal::Approve, function (array $payload) use (&$approvals): void {
$approvals[] = $payload;
});
$env->await(function () use (&$approvals): bool { return [] !== $approvals; });Les signaux servent aux circuits d’approbation, aux pauses et reprises, aux déclenchements externes.
Nommez les signaux par une énumération adossée à une chaîne, pas par un littéral. Les deux bouts prennent un BackedEnum, le gestionnaire qui consomme le signal comme le client qui l’envoie, de sorte qu’une énumération recense toute la surface de signaux d’un workflow, et qu’une faute de frappe devient une erreur de type plutôt qu’une attente qui ne se résout jamais (voir DUR034) :
$client->signal($workflowId, OrderSignal::Approve, ['by' => 'alice']);
$clientest leWorkflowClientInterfacede Temporal :signal()n’existe que sur Temporal. Avec le bundle Symfony, sur tous les backends, un signal est un message que vous envoyez sur le bus. Sur Temporal, le gestionnaire du bundle le transmet au cluster : la même ligne y marche aussi.use Gplanchat\Durable\Transport\DeliverWorkflowSignalMessage; $bus->dispatch(new DeliverWorkflowSignalMessage($workflowId, OrderSignal::Approve, ['by' => 'alice']));Routez
DeliverWorkflowSignalMessagedansmessenger.yamlcomme le fait Premiers pas. Le paquet Laravel ne livre pas encore de signaux.
Une chaîne nue reste acceptée, et doit l’être, parce qu’un signal peut arriver de curl, de la ligne de commande Temporal, ou d’un service écrit dans un autre langage. L’énumération type le côté application ; elle ne peut pas typer cette frontière.
Migrer depuis
waitSignal(). La méthode a disparu. Elle lisait l’historique directement, d’où son besoin d’un emplacement positionnel, d’un compteur par nom, et d’une règle pour l’attente qui renonce : toute une mécanique qu’un gestionnaire plus une condition remplacent purement et simplement (voir DUR035).// Avant $approval = $env->waitSignal(OrderSignal::Approve, Duration::hours(1)); // Après $env->onSignal(OrderSignal::Approve, fn(array $p) => $this->approvals[] = $p); $env->await(fn(): bool => [] !== $this->approvals, deadline: Duration::hours(1)); $approval = array_shift($this->approvals);Ce que vous gardez des livraisons est désormais de l’état de workflow : un workflow qui attend trois fois le même signal conserve trois entrées et les consomme à son rythme. Ce qui est arrivé alors que personne n’attendait est toujours là quand l’attente suivante se présente.
La requête#
Une requête est une lecture synchrone de l’état du workflow. Le workflow expose une #[AsQueryMethod] qui lit une variable interne ; l’appelant obtient la valeur courante sans modifier l’état. Les requêtes ne sont pas enregistrées dans l’historique.
Les requêtes servent à suivre une progression, lire un compteur, inspecter une liste d’éléments en attente.
La mise à jour#
Une mise à jour est un message transactionnel : le workflow la traite et renvoie une réponse à l’appelant. L’échange est enregistré dans l’historique. Les mises à jour combinent la sémantique du signal (changement d’état) et celle de la requête (valeur de retour).
La valeur de retour du gestionnaire est la réponse. C’est la différence avec un signal, qui n’en a pas :
#[AsUpdateMethod('greet')]
public function greet(array $arguments): string
{
$this->name = (string) ($arguments['name'] ?? 'World');
return $this->name; // ce que l'appelant reçoit
}Un gestionnaire qui lève fait échouer la mise à jour : l’appelant reçoit l’échec, et l’exécution se poursuit. Au rejeu, le gestionnaire s’exécute de nouveau pour reconstruire l’état qu’il a modifié, tandis que le résultat enregistré reste celui que l’appelant a déjà reçu.
Les mises à jour servent à incrémenter un compteur en renvoyant la nouvelle valeur, ou à des approbations conditionnelles qui doivent rendre un accusé de réception.
Les minuteurs#
WorkflowEnvironment::timer() renvoie un Awaitable qui se résout après une durée. Comme les activités, les minuteurs sont enregistrés durablement : après un redémarrage, leur état est reconstruit depuis l’historique et le workflow repart au bon moment, sans réexécuter les minuteurs déjà échus.
// Attendre 30 minutes avant de continuer
$env->await($env->timer(new \DateInterval('PT30M')));Les workflows enfants#
Un workflow peut lancer des workflows enfants pour découper un processus complexe en sous-unités suivies indépendamment. Chaque enfant a son propre historique et se surveille séparément dans l’interface Temporal.
Un workflow enfant peut tourner de façon asynchrone (lancé sans être attendu) ou être attendu par le parent.
Les backends#
Durable tourne sur quatre backends qui partagent le même code de workflows et d’activités :
┌──────────────────────────────────────────────────────────────────────┐
│ Code applicatif │
│ (workflows, activités, WorkflowEnvironment) │
└───────────────────────────────────┬──────────────────────────────────┘
│ même API
┌──────────────┬──────────────┼──────────────┐
▼ ▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ En mémoire│ │ DBAL │ │ Illuminate│ │ Temporal │
│ tests, │ │ une base │ │ une base │ │un cluster,│
│ local │ │ SQL, │ │ SQL, │ │à l'échelle│
│ │ │ Doctrine │ │ Laravel │ │ │
└───────────┘ └───────────┘ └───────────┘ └───────────┘En mémoire#
- Tourne entièrement dans un seul processus PHP.
- Aucun serveur externe, rien de persisté d’une requête à l’autre.
- L’envoi asynchrone des activités est simulé par un transport en processus.
- Convient à tous les tests automatisés et aux essais locaux rapides.
DBAL#
- Le journal, les métadonnées de reprise et les liens parent/enfant deviennent des tables dans une seule base SQL, à travers Doctrine DBAL.
- Aucun serveur d’orchestration, aucun sidecar, pas d’
ext-grpc. - Survit aux redémarrages et aux déploiements, à l’échelle qu’une base peut tenir.
Illuminate#
- Les mêmes quatre magasins et le même compromis, sur
Illuminate\Database\Connectionplutôt que sur celle de Doctrine, sur une connexion à elle plutôt que celle de l’application (DUR054). - Ce n’est pas une quatrième valeur de
backend, et ce ne le sera jamais : une application Laravel ne lit pas le YAML du bundle. Ce qui le branche, c’estgplanchat/durable-laravel, par son propreconfig/durable.php.
Temporal#
- Une orchestration de production, avec un vrai cluster Temporal.
- Historique persisté intégralement, réessais durables, interface Temporal, et les trois choses qu’aucun backend à journal n’a : les attributs de recherche, les planifications cron, et Nexus.
- Les workers (les processus qui tirent le travail : ils rejouent les workflows et exécutent les activités) interrogent Temporal en gRPC, par ce avec quoi l’hôte fait tourner ses workers.
- Nécessite l’extension PHP
ext-grpc.
Sur Magento, trois états sont possibles, et aucun n’utilise les ponts SQL.
gplanchat/durable-magentodéclare unconflictComposer sur les deux ponts :Magento\Framework\App\ResourceConnectionn’est ni Doctrine DBAL ni la connexion d’Illuminate. Son backend base de données est un adaptateur à lui, sur la connexion queresource/durabledésigne dansapp/etc/env.php. Un DSN dansdurable/temporal/dsnsélectionne Temporal. Sans l’un ni l’autre, le journal vit dans un processus. Déclarer les deux échoue au démarrage avecBackendSelectionException.
Pour la mise en place, voir Backends.
Par où le travail atteint un worker#
Durable n’a pas de transport propre. Le travail de workflow et d’activité passe par ce que l’hôte a déjà, et le mécanisme change d’un hôte à l’autre.
Symfony se sert de Messenger. ResumeWorkflowMessage est routé vers la file des tâches de workflow,
ActivityMessage vers celle des activités, et les signaux, les mises à jour et les échéances de
minuteur partent sur le bus synchrone. Avec le backend Temporal, le transport est un transport
d’interrogation adossé à gRPC : même interface de consommateur, protocole sous-jacent différent.
Laravel se sert de la file que l’application draine déjà. Les activités et les reprises sont des jobs,
un minuteur est un job de déclenchement différé sur le délai de la file, et php artisan queue:work est le seul
worker.
Magento ne se sert ni de l’un ni de l’autre. Les workers sont des commandes
bin/magento durable:worker --role=journal|activity qui interrogent le backend directement ; rien ne
passe par le MessageQueue de Magento. Sur Temporal, une activité est déjà une commande Temporal
et une reprise une tâche de workflow. Sur le backend base de données, les workers interrogent les files
en tables de la connexion du journal.
Une reprise est livrée au moins une fois#
Sur Symfony et Laravel, qui journalise un fait qu’un workflow attend envoie la reprise avant l’écriture, puis de nouveau après : le résultat d’une activité, celui d’un enfant rapporté à son parent, un signal livré, un minuteur échu. Un worker qui meurt entre les deux laisse une reprise derrière lui, et non un fait que personne ne reprend. Une reprise peut donc arriver plus d’une fois, et avant le fait qu’elle annonce. Celle qui arrive en avance attend ce fait par les nouvelles tentatives de la file. Une seconde livraison rejoue le journal et ne change rien. Un signal livré deux fois n’est journalisé qu’une fois : il porte l’identifiant de requête de sa livraison. Temporal livre lui-même les tâches de workflow, et ce contrat ne s’y applique pas. Le raisonnement est consigné dans DUR050 et DUR052.
Pour la configuration, voir Premiers pas et Référence de configuration.
Déterminisme et contrat de rejeu#
Le contrat de rejeu est la contrainte centrale : tout code à l’intérieur d’une #[AsWorkflowMethod] doit produire la même suite d’opérations awaitables à historique identique.
Autorisé dans un workflow :
- appeler les méthodes d’
activityStub()→ renvoie unAwaitable; - appeler
await(),all(),race(),any()→ suspendre ou combiner des awaitables ; - appeler
timer()→ poser un minuteur ; - lire l’état posé par
#[AsSignalMethod]/#[AsUpdateMethod]; - tout calcul pur sur des variables locales au workflow.
Interdit dans un workflow, à faire dans une activité :
new \DateTime()/time()/random_int(): non déterministes ;file_get_contents(),curl_exec(), requêtes en base ;- état mutable
staticou global partagé entre exécutions de workflow ; sleep(), la fonction PHP : utilisez$env->sleep(), qui suspend durablement au lieu de bloquer le worker.
Pour aller plus loin#
- Premiers pas couvre l’installation, la configuration et un premier workflow de bout en bout.
- Backends couvre la mémoire, DBAL, Illuminate et Temporal, la mise en place Docker, la référence des DSN.
- Écrire un workflow couvre l’API complète : signaux, requêtes, mises à jour, workflows enfants.
- Écrire des activités couvre
ActivityOptions, réessais, délais, injection de dépendances. - Tester des workflows couvre
DurableTestCase,ActivitySpyetDurableBundleTestTrait. - Référence de configuration liste chaque clé de
durable.yaml.