Annulation#

Quand vous annulez une exécution (un déroulement durable d’un workflow ; voir le glossaire), Durable ne la tue pas. L’annulation est levée à l’intérieur du workflow, à l’endroit où il attend, pour qu’il puisse compenser avant de se terminer. C’est l’équivalent du CanceledFailure de Temporal.


Compenser#

Pour défaire les étapes terminées avant un échec ou une annulation, enregistrez une compensation par étape avec Compensation, et exécutez-les depuis un catch :

use Gplanchat\Durable\Activity\ActivityStub;
use Gplanchat\Durable\Attribute\Activities;
use Gplanchat\Durable\Attribute\AsWorkflow;
use Gplanchat\Durable\Attribute\AsWorkflowMethod;
use Gplanchat\Durable\Exception\DurableActivityFailedException;
use Gplanchat\Durable\Exception\WorkflowCancelledFailure;
use Gplanchat\Durable\Workflow\Compensation;
use Gplanchat\Durable\WorkflowEnvironment;

#[AsWorkflow(name: 'checkout')]
final class CheckoutWorkflow
{
    /** @param ActivityStub<OrderActivities> $orders */
    #[AsWorkflowMethod]
    public function run(
        string $orderId,
        #[Activities(OrderActivities::class)]
        ActivityStub $orders,
        WorkflowEnvironment $env,
    ): string {
        $compensation = new Compensation();

        try {
            $reservation = $env->await($orders->reserve($orderId));
            $compensation->addCompensation(fn () => $env->await($orders->release($reservation)));

            $charge = $env->await($orders->charge($orderId));
            $compensation->addCompensation(fn () => $env->await($orders->refund($charge)));

            return $env->await($orders->ship($orderId));
        } catch (DurableActivityFailedException|WorkflowCancelledFailure $e) {
            $compensation->compensate();

            throw $e;   // l'exécution se termine annulée, ou en échec
        }
    }
}

Compensation enregistre une compensation par étape terminée et, à l’appel de compensate(), les exécute dans l’ordre inverse. Chaque compensation fait son propre await(), si bien qu’elle se termine avant que la suivante ne commence. Si une compensation renvoie un Awaitable à la place, compensate() lève une LogicException. Une étape qui ne s’est jamais terminée n’a rien à défaire, puisque sa compensation n’a jamais été ajoutée. La première compensation qui lève une exception arrête la série, et son exception remplace celle en cours de compensation.

La fin de l’exécution dépend de ce que le workflow fait de l’exception. Chacun de ces dénouements est légitime :

Le workflow… Dénouement
relance l’échec l’exécution se termine annulée
l’avale et rend une valeur l’exécution se termine normalement ; un workflow a le droit d’ignorer l’annulation
n’attend jamais rien l’annulation n’est jamais observée et le workflow se termine

L’opération attendue est annulée en même temps. Dans une course, toutes les branches en attente sont annulées.


Livrée exactement une fois#

L’annulation est levée une fois par exécution. Si elle était levée de nouveau, les attentes dont se sert la compensation seraient annulées à leur tour, et la compensation n’aurait jamais lieu.

Le rejeu (la réexécution du code du workflow depuis sa première ligne, où chaque étape enregistrée renvoie son résultat) reste déterministe parce que le dénouement figure dans le journal (l’historique, en ajout seul, de ce que l’exécution a décidé et reçu), sans marqueur à part. L’opération en attente est annulée avec la raison workflow_cancelled, et au rejeu ce dénouement enregistré rejette le même awaitable au même endroit. Le workflow prend donc la même branche à chaque rejeu.


Demander une annulation#

  • Depuis un parent. Quand le parent se ferme, un enfant planifié avec ParentClosePolicy::RequestCancel reçoit une demande d’annulation.
  • De l’extérieur, sur Temporal. Lancez temporal workflow cancel, ou appelez RequestCancelWorkflowExecution depuis n’importe quel client. Le serveur enregistre la demande et replanifie une tâche de workflow, que le worker traite ensuite.
  • Depuis votre application, sur Temporal. Appelez WorkflowClient::cancel($executionId) pour demander l’annulation, ou WorkflowClient::terminate($executionId, $reason) pour terminer l’exécution aussitôt, sans exécuter une ligne de plus du workflow. Les deux prennent l’ExecutionId avec lequel l’exécution a démarré. Sur une exécution terminée, elles lèvent WorkflowExecutionAlreadyEnded. Sur une exécution absente du serveur, elles lèvent WorkflowExecutionNotFound. Les deux portent l’identifiant dans executionId et l’échec du serveur dans getPrevious(). Rien n’est modifié. Terminer deux fois une exécution lève donc une exception au second appel.
  • Depuis votre application, sur le backend en mémoire. Appelez cancel($executionId) ou terminate($executionId, $reason) sur WorkflowClientPort. cancel() ajoute WorkflowCancellationRequested et reprend l’exécution : le workflow reçoit l’annulation là où il attend et compense dans son propre code. terminate() termine l’exécution par un WorkflowExecutionFailed de genre terminated (voir les genres d’échec) et n’exécute plus de code du workflow. Sur une exécution terminée, les deux lèvent WorkflowExecutionAlreadyEnded ; sur un identifiant qu’aucune exécution ne porte, WorkflowExecutionNotFound. Un second cancel() sur une exécution dont l’annulation est déjà demandée ne change rien. Aucun hôte ne lie encore WorkflowClientPort : une application ne peut pas l’injecter pour l’instant. DBAL, Illuminate et Magento n’ont pas encore ces méthodes.
  • De l’extérieur, sur les autres backends à journal. L’application n’a aucun point d’entrée pour demander une annulation sur DBAL, sur Illuminate ou sur Magento ; la matrice de capacités classe la ligne comme non prise en charge dans ces colonnes.

Ce qu’elle laisse dans le journal#

Événement Sens
WorkflowCancellationRequested une annulation a été demandée
WorkflowExecutionCancelled l’exécution s’est terminée annulée
ActivityCancelled / TimerCancelled avec la raison workflow_cancelled l’opération attendue a été retirée

Un perdant de course est annulé avec la raison race_superseded à la place, et remonte en ActivitySupersededException. Les deux situations restent distinguables.


Les perdants d’une course#

$winner = $this->environment->await(
    $this->environment->any(
        $this->quotes->callProvider($orderId),
        $this->quotes->callFallbackProvider($orderId),
    ),
    deadline: Duration::seconds(30),
);

Quand une branche gagne, les autres sont annulées. Leurs activités en attente sont retirées de la file, et leurs minuteurs en attente ne réveillent plus l’exécution. Une échéance écoulée les annule de la même façon, et lève DeadlineExceededException.

La borne de temps est l’échéance passée à await(), pas une troisième branche. Un minuteur mis en course avec les fournisseurs aurait l’air d’un gagnant. any() se résout à la valeur gagnante et à rien d’autre, si bien qu’un fournisseur qui répond légitimement null devient indiscernable de trente secondes de silence, et le chemin de compensation prévu pour le dépassement s’exécute aussi sur la réponse vide.

timer() renvoie bien un Awaitable, exactement comme un appel de stub : il peut donc être une branche. Employez-le comme branche quand le minuteur est un vrai dénouement, par exemple envoyer une relance ou prendre le chemin de repli. Ne l’employez pas comme échéance. Quand vous voulez seulement attendre, appelez sleep(), qui fait l’attente pour vous.

Écrire un workflow détaille l’échéance, avec ce que porte l’exception, deadline() et awaited().