Options et objets valeur#
Les options de planification (limites de réessai, délais, files de tâches, planifications cron, attributs de recherche) sont des objets valeur plutôt que des primitives. Chacun valide ce qu’il peut à la construction. Une erreur apparaît donc à la ligne où vous l’avez écrite, au lieu de surgir plus tard en rejet du serveur, en valeur réécrite en silence, ou en exécution (un déroulement durable d’un workflow ; voir le glossaire) qui attend indéfiniment.
Chaque règle appliquée ici a été éprouvée contre un serveur Temporal en marche avant d’être écrite. Là où le serveur est permissif, ces objets le sont en général aussi ; là où ils sont plus stricts, le docblock en donne la raison.
Duration#
Une longueur de temps. Remplace les champs ?float …Seconds.
use Gplanchat\Durable\Duration;
Duration::seconds(30);
Duration::milliseconds(250);
Duration::minutes(2.5);
Duration::hours(1);
Duration::zero(); // aucune attente
Duration::infinity(); // aucune borne : l'échéance par défaut d'await()
infinity() est une valeur ordinaire. Elle se compare (shortest(), isLongerThan()),
elle voyage dans la configuration, et elle évite au code qui calcule une échéance un cas
particulier pour « pas de borne ». Elle ne peut pas être transmise au serveur : timer() la
rejette, car un minuteur qui ne se déclenche jamais est une commande inscrite à l’historique pour
un réveil qui n’arrive jamais.
Elle accepte aussi les valeurs natives et Carbon, sans dépendre de Carbon :
Duration::of(new DateInterval('PT90S')); // CarbonInterval étend DateInterval
Duration::of(CarbonInterval::minutes(5));
Duration::until($deadline); // Carbon implémente DateTimeInterface
Duration::until($deadline, from: $from);
Duration::from($anything); // Duration|DateInterval|DateTimeInterface|int|float
$duration->toDateInterval();of() prend une longueur, until() prend un instant. Un DateTimeInterface ne devient une
durée qu’une fois mesuré contre un autre instant, d’où deux méthodes distinctes.
Une durée négative est rejetée, tout comme un INF ou un NAN calculé : une durée infinie se
crée par son nom, avec infinity(). Les unités calendaires (années, mois) n’ont pas de longueur fixe et sont
résolues contre une ancre UTC fixe : préférez les jours, les heures et les minutes pour une borne.
RetryLimit#
Combien de fois une activité peut s’exécuter. Une activité est une unité d’effet de bord dans un workflow : un appel HTTP, une écriture en base, un e-mail ; voir le glossaire.
use Gplanchat\Durable\Activity\RetryLimit;
RetryLimit::unlimited(); // aucune borne sur le nombre de tentatives (le défaut)
RetryLimit::ofAttempts(3); // trois tentatives au total
RetryLimit::ofRetries(2); // deux réessais, donc trois tentatives
RetryLimit::once(); // tout échec est définitif
L’illimité est le défaut, à l’image d’une
RetryPolicyTemporal sansmaximum_attempts. Une activité qui échoue systématiquement sans borner ses tentatives ne fait pas échouer le workflow ; elle réessaie indéfiniment. Seuls une exception non réessayable, un dépassement de délai ou une annulation l’arrêtent.Passez
RetryLimit::once()quand vous voulez qu’un échec soit définitif.
ofAttempts(0) est rejeté ; une limite non bornée s’écrit unlimited(). ofRetries(0) signifie
« pas de plafond », le sens que ce réglage a toujours eu dans la configuration du bundle.
ActivityTimeouts#
Les quatre bornes d’une activité, prises ensemble, parce que chacune borne un segment différent de sa vie :
planifiée ──planification-à-démarrage──▶ démarrée ──démarrage-à-clôture──▶ terminée
└────────────────── planification-à-clôture ──────────────────────────────┘
battement : le plus long silence toléré pendant l'exécutionuse Gplanchat\Durable\Activity\ActivityTimeouts;
use Gplanchat\Durable\Duration;
ActivityTimeouts::none(); // le backend décide
ActivityTimeouts::attempt(Duration::seconds(30)); // le cas courant : borner une tentative
(new ActivityTimeouts(
scheduleToStart: Duration::seconds(10),
startToClose: Duration::minutes(5),
scheduleToClose: Duration::minutes(30),
heartbeat: Duration::seconds(30),
));
// Chaque borne a son with…() qui rend une copie où seule celle-ci change ; null la retire.
ActivityTimeouts::attempt(Duration::seconds(30))
->withScheduleToStart(Duration::seconds(10))
->withHeartbeat(Duration::seconds(5));Un battement plus long que startToClose est rejeté : la tentative se terminerait avant le premier
battement manqué, et la borne de battement ne s’appliquerait donc jamais.
La borne de battement n’existe que sur Temporal. Un backend à journal (InMemory, DBAL, Illuminate,
Magento Database) lève UnsupportedByBackendException quand vous planifiez une activité avec une borne de battement.
Une activité sans startToClose est bornée à 30 secondes par tentative sur tous les backends,
comme sur Temporal ; scheduleToClose, s’il est défini, remplace cette valeur par défaut. Hors
Temporal, la vérification a lieu après le retour de la tentative ; rien ne l’impose pendant qu’elle
tourne. Une tentative qui a dépassé échoue sur un délai dépassé, son résultat est écarté,
et la politique de reprise détermine la suite. Rien n’interrompt une tentative qui ne rend jamais
la main. Arrêter un worker bloqué revient à ce qui supervise le processus.
messenger:consume --time-limit ne vérifie qu’entre deux messages, il ne peut donc pas l’arrêter.
scheduleToClose borne l’activité entière, reprises comprises. Sur Temporal comme sur les backends
à journal, une tentative échouée dont le délai de reprise dépasserait cette borne n’est pas
reprise : l’activité échoue avec l’échec de cette tentative et l’état de reprise Timeout. Sur les
backends à journal, la borne est aussi vérifiée quand un worker prend le message, et échoue avec
« Activity schedule-to-close timeout exceeded. » scheduleToStart s’applique à un message qui
attend dans la file. Sur Temporal, il s’applique à chaque tentative. Sur les backends à journal, il
ne s’applique qu’à la première, et une reprise qui attend son délai n’est bornée que par
scheduleToClose.
Temporal exige une borne de clôture. Quand aucune n’est posée, le pont en fournit une par défaut.
Ce repli porte son propre nom, executionBoundOr().
Assembler les options d’activité#
use Gplanchat\Durable\Activity\ActivityOptions;
// 3 tentatives, 30 s chacune, 1 s avant le premier réessai.
$options = ActivityOptions::of(3, 30, 1, [PaymentRefusedException::class], 'payments');of() prend ses arguments dans l’ordre où vous raisonnez : combien de tentatives, et combien de
temps chacune peut prendre. Il accepte des équivalents scalaires : un entier est un nombre de
tentatives, une durée nue est la borne startToClose d’une tentative, et un flottant est
un nombre de secondes. of(0) est rejeté ; il n’est pas lu comme « illimité ». La forme longue
reste disponible et strictement équivalente, pour quand vous voulez nommer chaque intention :
use Gplanchat\Durable\Activity\{ActivityOptions, ActivityTimeouts, RetryLimit};
use Gplanchat\Durable\{Duration, TaskQueue};
$options = new ActivityOptions(
RetryLimit::ofAttempts(3),
initialInterval: Duration::seconds(1),
nonRetryableExceptions: [PaymentRefusedException::class],
taskQueue: TaskQueue::named('payments'), // Temporal uniquement ; sur un backend à journal, cette option lève une exception
timeouts: ActivityTimeouts::attempt(Duration::seconds(30)),
);
// Les options sont portées par le stub : tous ses appels s'en servent.
$orders = $this->environment->activityStub(OrderActivities::class, $options);
$result = $this->environment->await($orders->charge($orderId));L’intervalle de réessai croît selon backoffCoefficient et se plafonne. Sans plafond explicite,
le défaut de Temporal s’applique : 100 × l’intervalle initial. Avec des tentatives illimitées,
ce plafond empêche un recul exponentiel de diverger.
WorkflowTimeouts#
Les trois bornes du workflow, qui s’emboîtent :
exécution ─┬─ run 1 ─┬─ run 2 (continue-as-new, réessai) ─ …
│ └─ tâche : un aller-retour de décision du worker
└────────────── exécution : toute la chaîneuse Gplanchat\Durable\{Duration, WorkflowTimeouts};
WorkflowTimeouts::none();
WorkflowTimeouts::run(Duration::minutes(10));
new WorkflowTimeouts(
execution: Duration::hours(1),
run: Duration::minutes(10),
task: Duration::seconds(10),
);
// Les mêmes copies with…() : withExecution(), withRun(), withTask() ; null retire la borne.
WorkflowTimeouts::run(Duration::minutes(10))->withTask(Duration::seconds(10));Une borne de run plus longue que la borne d’exécution est rejetée à la construction. Le serveur l’accepte et rabaisse silencieusement la borne de run à la borne d’exécution, si bien que la configuration que vous avez écrite n’est pas celle qui s’applique.
ContinueAsNewOptions rejette toute borne d’exécution : le run suivant
prolonge la chaîne de l’exécution courante et en hérite la borne. Employez withoutExecutionBound() pour y réutiliser
un WorkflowTimeouts. Ses propres copies sont withTimeouts() et withTaskQueue(), qui fait
passer le run suivant sur une autre file de tâches.
Les bornes qu’un workflow enfant reçoit sur les backends à journal#
Sur les backends en mémoire, DBAL, Illuminate et Magento Database, un enfant démarré avec
ChildWorkflowOptions::$timeouts reçoit les bornes suivantes. Temporal applique les trois.
| Borne | Backends à journal |
|---|---|
execution |
Appliquée à un enfant que le moteur exécute en ligne (le runner en mémoire, WorkflowTestEnvironment) : un enfant dont le prochain minuteur tombe après l’échéance fait échouer l’attente de son parent avec DurableChildWorkflowFailedException (« Child workflow <id> timed out. »). L’échéance est vérifiée quand l’horloge de l’enfant saute à un minuteur : le temps passé dans les activités et dans un petit-enfant exécuté en ligne n’est pas compté. Enregistrée dans un événement ChildWorkflowDeadline, pas encore appliquée, pour un enfant qui s’exécute sur un worker (ticket #1069). |
run |
Enregistrée, non appliquée. Elle ne diffère de execution qu’au travers d’un continue-as-new, où l’échéance doit repartir à chaque run. |
task |
Enregistrée, non appliquée. Elle borne un aller-retour du worker, que le journal n’a pas. |
L’identifiant d’exécution diffère selon le backend. Sur Temporal, le run suivant garde
l’identifiant d’exécution Durable. Sur les backends à journal, le journal génère un nouvel
ExecutionId pour lui. Voir
l’identifiant d’exécution après continueAsNew.
TaskQueue et WorkflowNamespace#
use Gplanchat\Durable\{TaskQueue, WorkflowNamespace};
TaskQueue::named('payments-activities');
WorkflowNamespace::named('billing');Les deux rejettent un nom vide, des espaces en bordure et des caractères de contrôle. Le serveur accepte les trois, mais ils ne sont jamais intentionnels. Pour une file de tâches, la conséquence est silencieuse : le travail est mis en file sous un nom qu’aucun worker n’interroge, et l’exécution attend, sans rien dans les logs.
Ni l’un ni l’autre n’attrape une faute de frappe qui reste un nom valide, tel que
payments-activitespourpayments-activities. Une file de tâches échoue en silence ; un espace de noms échoue bruyamment, enNOT_FOUND. Attraper la première demanderait un registre des files réellement servies.
La comparaison d’espaces de noms est sensible à la casse, comme sur le serveur : Billing et
billing sont deux espaces de noms différents.
CronSchedule#
Une récurrence, validée à la construction. Sans cette validation, une faute de frappe n’apparaît que lorsque le serveur rejette le premier démarrage.
use Gplanchat\Durable\{CronSchedule, Duration};
CronSchedule::parse('0 9 * * 1-5');
CronSchedule::daily(); // et aussi hourly, weekly, monthly, yearly
CronSchedule::every(Duration::minutes(90)); // @every 1h30m
CronSchedule::dailyAt(9, 30); // 30 9 * * *
CronSchedule::dailyAt(9)->inTimeZone('Europe/Paris');Sans fuseau horaire, le serveur lit l’expression en UTC, ce qui correspond rarement au sens de « tous les jours à 9 h ».
inTimeZone()émet le préfixeCRON_TZ=que lit le serveur.
La validation reproduit celle du serveur, expression par expression. Elle vérifie le nombre de
champs, les caractères, les plages et l’atteignabilité : 0 0 31 4 * est rejeté parce
qu’avril compte trente jours. Le jour de la semaine va de 0 à 6, donc 7 pour dimanche est rejeté. ? est accepté partout comme synonyme
de *.
Les deux erreurs les plus probables sont nommées dans le message : une expression à six champs (un cron Quartz copié d’ailleurs) et une planification sans aucune occurrence.
Voir Workflows récurrents plus bas pour en démarrer un.
SearchAttributes#
Ce par quoi une exécution peut être retrouvée.
use Gplanchat\Durable\{ExecutionId, SearchAttributes, WorkflowStartOptions};
$attributes = SearchAttributes::none()
->keyword('OrderId', 'ORD-4242')
->int('Amount', 4242)
->bool('Priority', true)
->double('Ratio', 0.75)
->text('Note', 'gift wrapping')
->datetime('DueAt', new DateTimeImmutable('2026-01-01'))
->keywordList('Tags', ['gift', 'express']);
$client->startAsync('CheckoutWorkflow', $input, ExecutionId::fromString($executionId), new WorkflowStartOptions(
searchAttributes: $attributes,
));
$clientest leWorkflowClientInterfacede Temporal, etstartAsync()n’existe que sur Temporal, comme les options de démarrage qu’il prend.WorkflowClientInterface::startAsync()etstartSync()déclarent l’argument?WorkflowStartOptions $options: le code typé contre l’interface peut le passer. Sur tous les backends, une exécution démarre parWorkflowResumeDispatcher::dispatchNewWorkflowRun()(Premiers pas), qui ne prend pas d’options de démarrage.
L’objet est immuable : chaque appel renvoie une nouvelle instance.
Deux des trois règles du serveur sont vérifiées localement :
- la valeur doit correspondre au type : un
Intqui reçoit une chaîne est rejeté avant l’aller-retour ; - seize attributs système sont en lecture seule (
RunId,WorkflowId,TaskQueue,StartTime, …).BuildIds,BinaryChecksumsetTemporalChangeVersionn’en font pas partie et peuvent être écrits.
La troisième ne peut pas être vérifiée localement : l’attribut doit être enregistré dans l’espace
de noms, et le vérifier supposerait de lire le registre de l’espace de noms. Le serveur répond
has no mapping defined for search attribute.
temporal operator search-attribute create --name OrderId --type Keyword
temporal operator search-attribute create --name Amount --type IntWorkflows récurrents#
use Gplanchat\Durable\{CronSchedule, ExecutionId, WorkflowStartOptions};
$client->startCron('NightlyReconciliation', $input, $executionId, CronSchedule::dailyAt(2));
// ou par l'objet d'options, aux côtés des délais et des attributs de recherche
$client->startAsync('NightlyReconciliation', $input, ExecutionId::fromString($executionId), new WorkflowStartOptions(
cronSchedule: CronSchedule::dailyAt(2)->inTimeZone('Europe/Paris'),
));Un cron Temporal est la même exécution logique, relancée par le serveur avec un historique neuf à chaque échéance ; aucun ordonnanceur externe n’intervient. Le run suivant ne démarre pas tant que le précédent n’est pas terminé, et une occurrence manquée est sautée, jamais rattrapée.
Les workflows enfants acceptent la même planification par ChildWorkflowOptions.
Le cron est une capacité de Temporal. Les backends à journal (mémoire, DBAL, Illuminate, Magento Database) n’ont pas d’ordonnanceur : le
cronSchedule, lenamespaceou letaskQueued’un workflow enfant y échoue avecUnsupportedByBackendException.
Réutiliser l’identifiant d’un workflow enfant#
ChildWorkflowOptions::$workflowIdReusePolicy fixe ce qui se passe quand un enfant démarre sous un
identifiant qu’une exécution antérieure a déjà utilisé. Une politique ne s’applique qu’à une exécution
terminée : démarrer un enfant sous un identifiant dont l’exécution tourne encore lève
ChildWorkflowIdInUseException sous toutes les politiques.
| Exécution antérieure sous l’identifiant | AllowDuplicate |
AllowDuplicateFailedOnly |
RejectDuplicate |
|---|---|---|---|
| Encore en cours | ChildWorkflowIdInUseException |
ChildWorkflowIdInUseException |
ChildWorkflowIdInUseException |
| Terminée avec succès | Une nouvelle exécution démarre | ChildWorkflowIdCompletedException |
ChildWorkflowIdAlreadyUsedException |
| Échouée, annulée ou terminée de force | Une nouvelle exécution démarre | Une nouvelle exécution démarre | ChildWorkflowIdAlreadyUsedException |
Chaque exception porte l’identifiant dans sa propriété childExecutionId et étend
\InvalidArgumentException. Sur les backends à journal (mémoire, DBAL, Illuminate, Magento
Database), l’appel de la méthode du stub lève l’exception. Sur Temporal, un parent qui
réutilise un identifiant dans son propre historique reçoit les mêmes exceptions à l’appel, car le
même contexte d’exécution lit cet historique. Un identifiant que seul un parent antérieur a utilisé
échoue autrement : le démarrage échoue côté serveur et le parent reçoit
DurableChildWorkflowFailedException à l’await de l’enfant. Les backends à journal vérifient le
flux propre de l’enfant, tous parents confondus, et lèvent donc les exceptions nommées à l’appel
dans ce cas aussi.
Mémo, résumé et détails d’un enfant#
ChildWorkflowOptions prend un mémo, un résumé d’une ligne et des détails plus longs pour l’enfant.
use Gplanchat\Durable\ChildWorkflowOptions;
$shipment = $env->childWorkflowStub(ShipmentWorkflow::class, new ChildWorkflowOptions(
memo: ['orderId' => 'ORD-4242'],
staticSummary: 'Ship order ORD-4242',
staticDetails: 'Two parcels, carrier chosen at dispatch',
));Les backends SQL et en mémoire enregistrent les trois dans le journal du parent, sur l’événement qui planifie l’enfant. Sur Temporal, le mémo devient celui de l’enfant, et le résumé et les détails deviennent les métadonnées utilisateur de la commande de démarrage, que l’interface de Temporal affiche sur l’enfant.
Le résumé et les détails demandent Temporal Server 1.25 ou plus récent. Un serveur plus ancien les ignore sans erreur, et l’enfant démarre sans eux. Le mémo passe sur tous les serveurs pris en charge.
Durable réserve les clés de mémo durableExecutionId et durableWaitingOn, et les écrit lui-même
dans le mémo d’un enfant sur Temporal. Sur tous les backends, new ChildWorkflowOptions() lève
UnsupportedByBackendException quand le mémo utilise l’une d’elles. Une exécution qui construit
de telles options échoue à cette ligne, y compris pendant le rejeu (le code du workflow qui tourne
à nouveau depuis sa première ligne pour reprendre ; voir le glossaire). Le rejeu
compare le type et l’entrée d’un enfant avec le journal, pas son mémo : une exécution en cours
reprend dès que le code utilise une autre clé.
Comportements qui diffèrent selon le backend#
Deux différences acceptées touchent les options de cette page. L’identifiant d’exécution après
continueAsNew est décrit plus haut. Un run dont le code ne correspond plus à son journal est mis
en attente sur Temporal et se termine en échec sur les backends à journal ; voir
la page des backends.
Migrer depuis l’API précédente#
Les arguments nommés ont changé en même temps que les objets valeur. Un appel non migré échoue immédiatement, avec une erreur.
| Avant | Maintenant |
|---|---|
maxAttempts: 3 |
RetryLimit::ofAttempts(3) en premier argument |
maxAttempts: 1 |
RetryLimit::once() |
maxAttempts: 0 |
RetryLimit::unlimited(), et c’est le défaut |
->withMaxAttempts(3) |
->withRetryLimit(RetryLimit::ofAttempts(3)) |
initialIntervalSeconds: 1.0 |
initialInterval: Duration::seconds(1) |
maximumIntervalSeconds: 60.0 |
maximumInterval: Duration::seconds(60) |
startToCloseTimeoutSeconds: 30.0 |
timeouts: ActivityTimeouts::attempt(Duration::seconds(30)) |
scheduleToStartTimeoutSeconds, scheduleToCloseTimeoutSeconds, heartbeatTimeoutSeconds |
arguments nommés d’ActivityTimeouts |
workflowRunTimeoutSeconds: 600.0 |
timeouts: WorkflowTimeouts::run(Duration::minutes(10)) |
workflowExecutionTimeoutSeconds, workflowTaskTimeoutSeconds |
arguments nommés de WorkflowTimeouts |
taskQueue: 'payments' |
taskQueue: TaskQueue::named('payments') (sur les options d’activité : Temporal uniquement ; sur un backend à journal, cette option lève une exception) |
namespace: 'billing' |
namespace: WorkflowNamespace::named('billing') |
cronSchedule: '0 9 * * *' |
cronSchedule: CronSchedule::parse('0 9 * * *') |
searchAttributes: ['OrderId' => 'x'] |
SearchAttributes::none()->keyword('OrderId', 'x') |
cancellationType: ActivityCancellationType::Abandon |
supprimé : aucun backend ne le lit, retirez l’argument (Rector le fait) |
Le comportement change en plus des signatures.
maxAttempts: 0voulait dire aucun réessai et veut maintenant dire illimité, comme sur Temporal. Une activité qui échoue systématiquement sans borner ses tentatives ne fait plus échouer le workflow. Repassez sur toute activité qui s’appuyait sur l’ancien défaut et posez-yRetryLimit::once()là où un échec doit être définitif.
Douze méthodes with*() d’ActivityOptions que personne n’appelait ont été retirées.
withRetryLimit() et withTimeouts() restent.
Les attributs de recherche avaient un second problème, moins visible : ils étaient écrits dans le journal (l’enregistrement de tout ce qu’une exécution a décidé et reçu) et jamais envoyés au serveur. Ils lui parviennent désormais, si bien qu’un attribut qui était silencieusement perdu peut maintenant être rejeté comme non enregistré. Enregistrez-le, ou retirez-le.