Après avoir passé en revue les bases pour bien démarrer avec les Azure Functions, cet article a pour but d’explorer une fonctionnalité des Azure Functions, les « Durable Functions ». Ces dernières sont malheureusement trop méconnues alors qu’elles peuvent répondre à de nombreux scénarios.
Les Durable Functions s’exécutent sur le même framework que les Azure Functions et apportent de nouvelles capacités. Contrairement aux Azure Functions qui sont sans état (stateless) elles gèrent un état (stateful) leur permettant de gérer un contexte d’exécution, de stocker des variables d’état pour les partager entre plusieurs functions. Elles peuvent suspendre puis reprendre leur exécution après un arrêt. Enfin, cette caractéristique est intéressante : elles n’ont pas de limite de temps d’exécution et peuvent s’exécuter sur de longues périodes, qui peuvent aller jusqu’à l’infini.
1. Types de functions
Pour comprendre le principe, le modèle d’exécution est basé sur un concept d’une fonction d’orchestration principale (l’orchestrator), qui va des fonctions d’activité (activities) qui s’exécuteront en séquence ou en parallèle. Il existe différents types de Durable Functions dans Azure, ce que nous allons explorer dès maintenant.
Globalement, L’orchestrator conserve l’état d’exécution des activités avec ses paramètres et peut rependre son exécution à tout moment en cas d’interruption ou de suspension. C’est ce qui lui permet, par exemple, de s’arrêter pour attendre un événement externe, puis de rependre quand l’événement arrive ou à l’issue d’un timeout. Entre l’arrêt et la reprise, il peut s’écouler plusieurs heures ou jours. C’est donc un candidat idéal pour gérer des workflows.
1.1. Function Orchestrator
La function Orchestrator, comme son nom l’indique, orchestre et organise la façon avec laquelle les activités vont s’exécuter. Par exemple, il décide si les activités sont exécutées en série ou en parallèle. Il gère les passages des paramètres, les données de retours et la gestion des exceptions. Les types de méthodes qu’un orchestrator peut appeler sont les activités, les sous-orchestrator, les attentes d’événements externes, les timers, et d’autres encore.
Un orchestrator est défini par un attribut de méthode [OrchestrationTrigger].
Voici un exemple d’orchestrator.
Ligne 1 : On déclare la méthode en tant que Function nommée « RunOrchestrator »
Ligne 3 : On déclare la function en tant que OrchestrationTrigger ce qui permet de recevoir un TaskOrchestrationContext en paramètre.
Ligne 9 : on appelle context.CallActivityAsync en passant en paramètre le nom de l’activity et un paramètre. Le nom de l’activity pourrait être également nameof(Activity.SayHello), Activity étant le nom de la classe et SayHello le nom de la méthode. Cependant, une simple chaîne de caractère avec le nom de function fonctionne pareillement.
Ligne 11 : on retourne un résultat. Ici, c’est une chaine de caractères.
1.2. Function Activity
Les activités sont les unités de travail élémentaire. Ce sont les fonctions qui vont réaliser le travail. Elles recevront le plus souvent des paramètres de l'orchestrator, puis retourneront une valeur, typiquement pour remonter le statut de la tâche qu'elles ont réalisées.
Une activité est définie par un attribut de méthode [ActivityTrigger].
Voici un exemple d'activity.
Ligne 1 : On déclare la méthode en tant que Function nommée « SayHello »
Ligne 2 : L'attribut de paramètre [ActivityTrigger] déclare la méthode comme une activity.
Ligne 4 : un ILogger a été créé dans le constructeur de la classe (non montré dans l'exemple), que l'on utilise ici.
Ligne 6-11 : c'est le corps de la méthode. On simule une exécution « longue » (entre 60 et 70 secondes), et on retourne un message.
1.3. Function Entity
Les entités (Entity) sont des fonctions qui permettent de lire et écrire des données d’état. Un état (ou state) est une donnée stockée que l’on décide de conserver à un instant donné de l’exécution. Elle permet de persister des valeurs qui peuvent être relues à tout moment même après une mise en sommeil (ou un crash). Par exemple, dans un jeu, une fonction Entity peut conserver le score des joueurs ou un compteur qui doit être relu ultérieurement. Les fonctionsEntity permettent de lire et écrire des valeurs d’état explicitement. L’espace de stockage par défaut des entités est un compte de stockage blob Azure, géré par la fonction, vous n’avez rien à faire par vous-même. Grâce à des Storage Providers personnalisés, vous pouvez décider d’enregistrer le state dans une base de données comme Azure SQL Database.
Une function entity est définie par un attribut de méthode [EntityTrigger].
1.4. Function Client
Les orchestrators ne peuvent pas démarrer seul, il faut les lancer à partir d'un client qui va fournir un contexte. Ainsi, un client est une fonction de démarrage de l'orchestration. C'est une function ordinaire à laquelle on donne le statut de client d'une orchestration grâce à un attribut spécial.
Une function Client est définie par un attribut de paramètre [DurableClient].
Voici un exemple de Durable Client.
Ligne 1 : On déclare la méthode en tant que Function nommée « Function1_HttpStart »
Ligne 3 : La function est de type HttpTrigger et supporte les verbes http GET.
Ligne 4 : La function est un DurableClient ce qui lui donne la possibilité de recevoir un objet client pour appeler un orchestrator.
Ligne 5 : la function reçoit un FunctionContext pour appeler des fonctions spécifiques au durable functions.
Ligne 8 : On démarre un orchestrator nommé « Orchestrator ». Le nameof fait référence au nom d'un orchestrator (nom de la function, et non de la méthode). Notez que la méthode retourne une chaine instanceId (Guid) qui permet d'identifier l'instance de l'orchestrator. Vous pouvez ainsi exécuter plusieurs instances d'un même orchestrator en prallèle. Vous serez ensuite capable de les distinguer grâce à leur identifiant.
Ligne 14 : le client renvoie un résultat sous la forme d'un json qui contient les URLs de gestion de l'orchestrator. Par exemple :
2. Les Durable Functions, pour quoi faire ?
Voici les principaux scénarii auxquels répondent les Durable Functions.
2.1. Chainage d’appels
Ce modèle d’exécution consiste à enchainer les appels à des functions en série. Une function est appelée lorsque la précédente est terminée. C’est un modèle très simple qui permet d’exécuter une série de tâches qui sont dépendantes les unes des autres en s’assurant de leur exécution dans le bon ordre.
Le code suivant donne un exemple de ce type d’appel.
Ligne 1 : On déclare la méthode comme étant une Function (avec un nom).
Ligne 2-3 : La Function est un orchestrator, et on reçoit un paramètre « context ».
Ligne 7-10 : on appelle en séquence les activités en y passant des paramètres. Vous noterez que les activités reçoivent en paramètre la valeur de retour de l’appel précédent.
Il y a un try/catch pour gérer les potentiels exceptions qui surviendraient pendant l’exécution.
2.2. Appels en parallèle de Functions
Ce modèle appelle de multiple Function en parallèle. Dans cet exemple, F1 est une activité de préparation des appels suivants. F2 est l’activité qui doit s’exécuter en parallèle. F3 est une activité de consolidation des résultats calculés par F2. Seul F2 est obligatoire, F1 et F3 sont des activités optionnelles.
Ligne 1 : on déclare la méthode comme étant une Function (avec un nom).
Ligne 2-3 : la Function est un orchestrator, et on reçoit un paramètre « context ».
Ligne 5 : on instancie une liste de Task qui vont retourner une valeur de type int.
Ligne 8 : on appelle une première activité qui initialise des données (ou un contexte) pour préparer les tâches qui suivront.
Ligne 9-15 : on appelle dans une boucle les tâches en leur passant les données préparées (workbatch), et on attend qu’elles aient toutes terminé leur travail avant de continuer.
Ligne 18-19 : on appelle une tâche finale pour consolider les résultats. Dans ce cas, on fait une simple somme des valeurs retournées.
2.3. Appels http asynchrones
Ce modèle permet de résoudre un problème courant qui est d’appeler une API dont le temps d’exécution est non déterminée à l’avance et dont on veut connaitre le statut pour savoir si elle est terminée. Ce dispositif évite d’avoir à gérer les contraintes de timeout d’une API classique.
La technique consiste simplement à démarrer un orchestrator, puis à sonder son statut régulièrement pour savoir s’il est terminé.
Ligne 1: on démarre une orchestration, ce qui retourne un identifiant b79baf67f717453ca9e86c5da21e03ec.
Ligne 8 : on appelle l’instance de l’orchestrator. Vous noterez l’identifiant dans le chemin de l’URL, et on reçoit en retour un json qui contient le statut d’exécution (ligne 13). À ce moment-là, il est « Running ».
Ligne 15 : on fait un nouvel appel à l’instance qui retourne cette fois-ci un statut « Completed » (ligne 20). On sait alors que l’exécution est terminée.
2.4. Monitor
Ce modèle est basé sur le même principe que le précédent, mais cette fois-ci le pooling est effectué par un orchestrator qui va se programmer pour sonder à intervalles réguliers le statut d’exécution. Vous pourriez songer à faire la même chose avec timer, mais la différence majeure ici est que l’orchestrator va gérer son state d’exécution et pourra reprendre en cas d’interruption (crash, scale-out/in de la plateforme, interruption volontaire) d’une part, et que les intervalles sont dynamiques et non statiques comme avec un timer. Un intervalle dynamique signifie que le démarrage de l’exécution suivante se calcule en fonction de la durée de l’exécution précédente et ainsi évite les chevauchements.
Ligne 1-3 : déclaration classique d’un orchestrator.
Ligne 8 : on boucle tant qu’une date d’expiration n’est pas atteinte.
Ligne 10 : on appelle une activité qui retourne le statut d’un job.
Ligne 11 : on teste le statut et on appelle une activité qui réalise une action. Ici, on envoie une alerte, mais cela pourrait être une mise à jour dans une base de données, par exemple. Puis, on sort de la boucle (ligne 15).
Ligne 19-20 : on calcule la date de la prochaine exécution, et on la programme grâce à la méthode CreateTimer de l’objet context de l’orchestrator.
2.5. Interaction Humaine
Ce modèle permet d’impliquer une interaction humaine dans un workflow. Avec un orchestrator cela devient possible, car son exécution peut être suspendue le temps que l’humain réponde à une demande validation, ce qui peut prendre plusieurs heures ou jours.
Imaginez un scénario dans lequel un manager reçoit une demande de validation de congés. L’orchestrator doit se mettre en veille jusqu’à ce que la réponse arrive où qu’un délai d’expiration soit atteint.
Ligne 1-3 : déclaration classique d’un orchestrator.
Ligne 5 : on envoie une requête pour approbation de la demande (envoi d’un mail par exemple).
Ligne 6 : on crée un token d’annulation de tâche (CancellationToken)
Ligne 8-9 : on crée un timer qui va se déclencher au bout d’un délai donné (ici 72 heures). Vous noterez que le timer est créé à partir de l’objet context de l’orchestrator.
Ligne 11 : cette ligne est intéressante et démontre les capacités d’un orchestrator. On se met en attente d’un événement externe qui, dans notre cas, sera le retour de la validation de l’utilisateur. Cet événement pourra être déclenché par l’arrivée d’un message dans une file d’attente (queue). L’événement « ApprovalEvent » sera une autre function qui, branchée sur la file d’attente, recevra ce message.
Ligne 12 : On se met en attente des déclencheurs (l’arrivée du message de réponse ou le timeout). Soit, on reçoit une réponse de l’utilisateur avant 72 heures, soit le timer expire. Dans le premier cas, on exécute les lignes 14-15, dans le deuxième, on exécute la ligne 19. Vous noterez que la méthode WhenAny va retourner le nom de l’événement « ApprovalEvent » si l’utilisateur répond avant l’expiration.
Pour appeler ApprovalEvent, on utilisera, comme mentionné, une autre Function avec un trigger classique (une queue, un service bus, une simple API, etc). Dans l’exemple ci-dessous, on utilise un HttpTrigger.
Ligne 4 : on déclare un DurableClient.
Ligne 7 : on appelle une méthode RaiseEventAsync du client pour déclencher l’événement que l’orchestrator est en train d’attendre.
2.6. Aggregator
Ce modèle permet de consolider des données d’événements diverses qui proviennent de différentes sources, typiquement produites par des traitements batchs et qui peuvent avoir été démarré sur de longues périodes de temps. Ce modèle peut être implémenté à l’aide de Function Entity.
Dans cet exemple, la fonction Counter stocke un compteur qui peut être lu, incrémenté, réinitialisé ou supprimé. Le code ci-dessous est valable pour les fonctions qui s’exécutent avec un worker process isolé, d’où l’utilisation d’un dispatcher en paramètre.
Ligne 2 : on reçoit un dispatcher en paramètre.
Ligne 4 : le dispatcher va retourner le résultat d’une méthode anonyme qui sera la valeur du state.
Ligne 11 : En fonction du nom de l’opération effectuée sur le state, on va exécuter une action correspondante, et retourner (si besoin) la nouvelle valeur du state.
Vous noterez que la méthode est static. Mais vous pouvez l’implémenter également sous la forme d’une classe.
La Function Entity est ensuite appelée par le client en plaçant dans une queue interne les opérations. On appelle cette opération le « signaling ».
Dans l’exemple ci-dessous, une fonction s’abonne à des messages d’un Event Hub, récupère un indicateur « metric », et pour chacun de son type récupère sa valeur, puis enfin, incrémente sa valeur en le stockant dans le state.
3. Fonctionnalités étendues des Durable Functions
Il existe un certain nombre de fonctionnalités avancées qu’il est intéressant de connaître pour exploiter pleinement le potentiel des Durable Functions.
3.1. SubOchestrator
Un orchestrator peut être appelé par un autre orchestrator. Il est perçu par l’appelant comme une activité, et peut recevoir des paramètres et renvoyer une valeur de retour.
L’appel se fait en utilisant la méthode « CallSubOrchestratorAsync ».
Ligne 11 : Plusieurs instances de l’orchestrator DeviceProvisioningOrchestration sont créées et appelées dans le Task.WhenAll.
3.2. Timers
Les timers des Durable Functions sont utilisés pour programmer le démarrage d’une tâche, ou pour déclencher une expiration de délai (timeout).
La programmation d’une tâche permet de créer des récurrences dans le temps. Dans l’exemple ci-dessous, on envoie un message quotidien sur les 10 prochains jours.
Ligne 7: on crée une date en ajoutant une durée d’une journée à partir de la date courante remontée par le contexte de l’orchestrator.
Ligne 8 : on crée le timer en utilisant la date calculée. Notez l’utilisation de await qui met la function en sommeil jusqu’à la date indiquée.
L’exemple ci-dessous met en œuvre une expiration de délai (ou timeout). Ici le modèle est assez différent. On crée un CancellationToken pour annuler le timeout si la tâche qui s’exécute est terminée. Puis, on exécute un WhenAny(tache, timeout) en attendant qu’un des deux se déclenche.
Vous noterez que le CreateTimer n’utilise pas de await. L’appel n’est donc pas bloqué comme dans le cas précédent.
3.3. Orchestrator éternel
Un orchestrator éternel s’exécute indéfiniment et ne s’arrête jamais, sauf si vous le décidez. Ce sera typiquement un scénario de nettoyage de données qui doit s’exécuter à intervalles réguliers.
Les orchestrators conservent un historique du détail de leur exécution. De fait, nous déconseillons d’utiliser des boucles infinies pour exécuter des tâches en continu, car l’historique va grossir indéfiniment et va pénaliser les performances. À la place, la bonne pratique consiste à créer un timer qui va relancer l’orchestration et réinitialiser l’historique à chaque exécution en appelant la méthode ContinueAsNew.
L’exemple ci-dessous montre l’appel d’une activité « DoCleanup », puis la création qui déclenchera l’orchestrator une heure plus tard, suivi de l’appel de ContinueAsNew.
Pour démarrer l’orchestrator, il faut utiliser la méthode StartNewAsync, comme le montre l’exemple ci-dessous.
Enfin, pour arrêter une orchestration éternelle, il faut simplement la laisser sortir sans exécuter ContinueAsNew.
4. Conclusion
Cet article vous donne un aperçu des possibilités offertes par les Durable Functions. Il existe d’autres fonctionnalités qui sont développées dans la documentation de Microsoft.
Dans la construction d’une application dans Azure, pensez à les utiliser et ne vous lancez pas dans un développement spécifique pénible à réaliser qu’une Durable Function pourrait couvrir avec plus de fiabilité.
Notez que les Azure Function (Durable ou non) peuvent s’exécuter dans des containers. Vous pouvez donc les lancer sur des ressources de type Web App for Containers, Container Apps, ou AKS, ou directement sur un cluster Kubernetes.
5. Références
- What are Durable Functions? : https://learn.microsoft.com/en-us/azure/azure-functions/durable/durable-functions-overview?tabs=in-process%2Cnodejs-v3%2Cv1-model&pivots=csharp
- Durable Functions types and features : https://learn.microsoft.com/en-us/azure/azure-functions/durable/durable-functions-types-features-overview
- Sample code: https://learn.microsoft.com/en-us/samples/browse/?products=azure-functions&term=durable&terms=durable
- Storage Provider MSSQL : https://learn.microsoft.com/en-us/azure/azure-functions/durable/quickstart-mssql

