L’idempotence

L’idempotence, pourquoi est-elle indispensable ?

Le sujet de l’idempotence dans le SI est un sujet central. En mathématiques et en informatique, le concept d’idempotence signifie qu’une opération produit le même effet, qu’elle soit exécutée une ou plusieurs fois. En informatique, on caractérise l’effet de l’opération par l’état du système qui en résulte. Hors infrastructure. Il peut s’agir d’un état en mémoire, ou d’un jeu de données en base. L’idempotence peut être obtenue par la nature des opérations à mener, ou par celui (plus complexe) de la résilience aux erreurs, cette faculté qu’a le SI de savoir rebondir justement après des dysfonctionnements. Cela dépend du domaine. Dans le premier cas, il peut s’agir d’opérations en lecture seule exclusivement, ou d’opérations pouvant être exécutées plusieurs fois sans que cela ne se remarque. Par exemple, lorsque l’on supprime plusieurs fois une même ligne sous SSMS. Dans le second cas, dans des domaines plus riches et contre des processus métier complexes, l’idempotence est une caractéristique qu’il est nécessaire de construire, car elle découle très rarement de façon naturelle de ces processus.

L’idempotence est une ligne directrice. Aucun traitement n’est réellement idempotent. Le simple fait de tracer les appels ou la prise en compte d’événements dans le SI rend le système non idempotent. C’est pourquoi il est nécessaire de bien séparer qui appartient à l’exploitation, à l’infrastructure, et au métier.

Quelques exemples

Exemple d’un crédit : les clients ayant récemment terminé le remboursement de leurs mensualités doivent pouvoir recevoir un courrier légal d’attestation de solde, 30 jours après la dernière mensualité. Afin de compléter les envois postaux automatiques, on souhaite pouvoir, sur sollicitation de certains clients (que l’on souhaite rapidement fidéliser), réaliser un envoi anticipé. Nous attendons que ce système soit idempotent, car nous ne souhaitons pas que le client puisse recevoir 2 attestations différentes. L’une issue d’un envoi manuel, l’autre d’un traitement de masse.

Dans ce cas, l’idempotence est construite :

  • Par une contrainte d’intégrité composite en base, devant mener à l’unicité, dans la base de données. Quel client ? Quel contrat ? Quel type de document ? Une clé d’unicité peut être créée à partir de ces différentes informations.
  • Par des contrôles détaillés avant toute exécution. A savoir, une attestation existe t-elle déjà ? Cette option est plus sexy, car il est possible d’encadrer les différents domaines par des règles de gestion spécifiques, en ne déléguant pas toute l’intelligence métier à la base de données.

Les 2 options sont complémentaires. Il est toujours bon de sécuriser le CRUD en base par des contrôles applicatifs. En aparté, veiller au caractère ACID des transactions réalisées est une première étape, un gage de confiance, un prérequis nécessaire. Si une erreur survient, la transaction avortée permet de ne pas altérer partiellement les données. Et le traitement fournit des résultats très prévisibles, quelque soit la situation. Si le traitement s’arrête inopinément au milieu de nulle part, le système passe dans un état inconsistant dont il sera très difficile de se remettre. Le snippet suivant permet d’illustrer ce propos. Le délégué isKnown permet de savoir si le traitement process prenant en paramètre l’objet notification a déjà été exécuté. Le contrôle porte sur ce dernier objet :

private void HandleIdempotentEvent<T>(Func<T, bool> isKnown, Action<T> process, T notification)
{
    using (var transaction = new TransactionScope())
    {
        try
        {
            if (isKnown.Invoke(notification))
            {
                // Run
                process.Invoke(notification);
                transaction.Complete();
            }
        }
        catch
        {
            // Log
        }
    }
}

L’idempotence est une notion souvent bien comprise, et elle peut s’appliquer de façon très générale à de nombreux endroits du système, sans risque particulier. Dans un micro-servicée, ou REST, elle doit encadrer les appels aux APIs. En , elle doit tout particulièrement encadrer les “Domain Events” (DEs) : nominalement, le prévoit l’émission de notifications sur un bus d’entreprise, par un producteur. Ces messages sont consommés par des consommateurs, qui souscrivent aux messages en fonction de leurs intérêts propres.

Asynchronisme

Dans le cas de l’envoi postal ci-dessus, les problématiques sont très intelligibles. 2 notions apparaissent distinctement : la création d’une identité, et la création d’un historique ou d’une piste d’audit. L’objet en entrée du traitement n’est donc jamais qu’un simple DTO, car il devrait avoir une identité ! L’identité du message nous permet de définir ce dont il est question, de manière déterministe. La piste d’audit nous permet de donner une mémoire au système, d’observer le passé et de détecter d’éventuelles occurrences passées concernant l’intégration de la notification. Les 2 composantes sont complémentaires. Pour des traitements complexes, l’un ne va pas sans l’autre. Mais dans le cas du DDD et de la consistance éventuelle, les choses ont tendance à se compliquer.

Exemple d’une commande réalisée sur un site de vente en ligne : lors de l’envoi de la commande et après avoir constaté l’intégrité du contenu du colis, le préparateur scanne le code barre du colis. L’information est routée de façon asynchrone vers le service Shipping du système d’informations qui émet à son tour un accusé de réception de modification du statut de la commande, qui donnera lieu à la validation du paiement par le service Billing.

var publisher = new Publisher();
var barCode = new BarCode("4 007630 00011X");

publisher.Publish(new PackageHasBeenShippedEvent(barCode));

Après exécution de la requête, le service Shipping s’acquitte par une notification :

publisher.Publish(new StatusHasBeenChangedEvent(barCode));

Le système doit être prémuni contre les erreurs de saisies multiples. En effet, le colis ne peut être envoyé qu’une seule fois, même si le colis est scanné 2 fois. La carte ne doit être débitée qu’une seule fois par le service Billing. Les notifications qui suivent ne doivent pouvoir être traitées (en temps opportun) qu’une seule et unique fois à l’intérieur d’un BC donné, et par extension, à l’extérieur de ce BC. En plus de nous assurer que les traitements sont intrinsèquement idempotents, nous devons pouvoir identifier les messages reçus, et vérifier s’ils ont déjà fait l’objet d’un traitement par le BC, ou pas. Dans ce cas, chaque gestionnaire de notifications de chaque BC doit garder trace des messages traités, quel que soit le lieu de leur origination, afin de pouvoir les identifier, et afin de jeter les doublons. La question de l’identité et de sa propagation devient vraiment centrale. Admettons qu’un double scan ait lieu…

Piste d’audit et identité des événements

Nous devons faire en sorte que les commandes et les notifications aient une identité dès leur création. Cette identité va suivre le message jusqu’à la fin des temps. Chaque consommateur doit intercepter les messages auxquels il a souscrit, et les persister en base, avant tout traitement par le BC. Si le traitement va jusqu’au bout sans erreur, le message doit être acquitté localement, et il sera impossible de l’intégrer une nouvelle fois. Il est capital de comprendre qu’aucune application ne peut modifier l’identité du message. Cette identité unique se perpétue dans chaque noeud applicatif du SI. Le message est dupliqué dans tout le SI, même si lui même ne sert qu’à répliquer un certain nombre d’informations. Si l’identité du message et un Guid, ce Guid ne pourra pas être calculé à l’insertion par aucune des bases de données des applications clientes. Seule l’application originatrice de la notification est autorisée à déterminer l’identité du message. Cette identité pas qu’une simple information technique. De cette façon, les notifications traversent le SI en étant clairement identifiables et chaque BC est en mesure de comprendre de quelle notification il est question ! De façon transverse.

Prérequis : chaque BC doit précisément garder trace de ce qu’il a émis, et de ce qu’il a consommé. Déléguer la définition de l’identité des commandes au SGBD ne le permet pas.

Si les notifications sont persistées dans une structure interne au BC, le critère isKnown peut être écrit comme suit :

var eventStore = new EventStore<T>();
var isKnown = !eventStore.Get().Any(e => e.Guid == @event.Guid && e.Done);

Et avec :

private void HandleIdempotentEvent<T>(Func<T, bool> isKnown, Action<T> process, T notification) where T : DomainEvent
{
    using (var transaction = new TransactionScope())
    {
        try
        {
            if (isKnown.Invoke(notification))
            {
                // Run
                process.Invoke(notification);
                notification.Done = true;
                transaction.Complete();
            }
        }
        catch
        {
            notification.Done = false;
            // Log
        }
    }
}

Do not let the database dictate identities… !

internal class DomainEvent
{
    public DomainEvent(Guid identity)
    {
        Identity = identity;
    }

    public Guid Identity { get; }
    public bool Done { get; set; }
}

Si les notifications n’ont pas d’identité, nous devons leur en construire une. Mais il est toujours nécessaire de persister le message, pour des enjeux d’audit, sans oublier d’indexer la colonne représentant le hash ainsi calculé.

private static ulong CreateHashCode<T>(T obj)
{
    ulong hash = 0;
    var objType = obj.GetType();

    if (objType.Namespace != null && (objType.IsValueType || objType.Namespace.StartsWith(@"System")))
    {
        unchecked
        {
            hash = (uint) obj.GetHashCode()*397;
        }
    }
    else
    {
        // ReSharper disable once LoopCanBeConvertedToQuery
        foreach (var property in obj.GetType().GetProperties())
        {
            var value = property.GetValue(obj, null);
            hash ^= CreateHashCode(value);
        }
    }

    return hash;
}

Plus le contenu du message est explicite sur les circonstances de son origination, plus nous serons en mesure d’identifier sa première occurence fonctionnelle (sa naissance), et plus son identité calculée sera déterministe. Des éléments précis de contexte permettent de trouver les bons discriminants qui interviennent dans la construction de cette identité. Ainsi, il peut être judicieux que le message embarque, par exemple :

  • L’horodatage de sa création, à savoir la date système de production par le producteur…
  • L’identifiant de l’utilisateur qui a sollicité (directement ou indirectement) sa création…
  • L’identifiant de l’applicatif en amont de la demande (adresse MAC, adresse IP, nom logique du nœud applicatif, etc…). Encore une fois, cela dépend du domaine… Au niveau du domaine, nous pourrions peut-être avoir besoin d’être en mesure d’ajouter deux fois le même article. De toute évidence, il est important ici de comprendre ce que votre système dire par “même article”…

Ces éléments nous permettent d’identifier une saisie double : si un chargé de CRM valide successivement 2 remboursements anticipés pour un même client à 0.2 secondes d’interval, cela doit attirer l’attention et empêcher une intégration double s’il est avéré qu’il s’agit d’un doublon. Revenons à l’exemple. Si le colis est scanné 2 fois par erreur, la séquence suivante sera produite :

{"$type":"Idempotence.PackageHasBeenShippedEvent, Idempotence","BarCode":{"$type":"Idempotence.BarCode, Idempotence"},"Identity":"36432cb7-99cd-4852-b3a1-663a2abec1d8","CreationDate":"2017-05-31T16:50:00.7117718+02:00","MacAddress":{"$type":"System.Net.NetworkInformation.PhysicalAddress, System"},"User":"venantvr","Done":false}

{"$type":"Idempotence.StatusHasBeenChangedEvent, Idempotence","BarCode":{"$type":"Idempotence.BarCode, Idempotence"},"Identity":"48d0c9b8-1ff1-46f7-8e31-b6a76f8efef4","CreationDate":"2017-05-31T16:50:03.5127191+02:00","MacAddress":{"$type":"System.Net.NetworkInformation.PhysicalAddress, System"},"User":"venantvr","Done":false}

{"$type":"Idempotence.PackageHasBeenShippedEvent, Idempotence","BarCode":{"$type":"Idempotence.BarCode, Idempotence"},"Identity":"fb37407c-3215-4066-ae7d-2270c16e5d0d","CreationDate":"2017-05-31T16:50:04.3309075+02:00","MacAddress":{"$type":"System.Net.NetworkInformation.PhysicalAddress, System"},"User":"venantvr","Done":false}

{"$type":"Idempotence.StatusHasBeenChangedEvent, Idempotence","BarCode":{"$type":"Idempotence.BarCode, Idempotence"},"Identity":"61961ec5-a3a0-47f7-abf3-33484a1f561b","CreationDate":"2017-05-31T16:50:05.5477651+02:00","MacAddress":{"$type":"System.Net.NetworkInformation.PhysicalAddress, System"},"User":"venantvr","Done":false}

La séquence est donc la suivante. Le premier scan donne lieu à un premier message PackageHasBeenShippedEvent. Son traitement donne lieu à un autre message StatusHasBeenChangedEvent. Cette séquence a lieu 2 fois. A chaque fois, un nouveau Guid est généré et on observe bien qu’il est impossible de dédoublonner ces messages, malgré l’existence d’une identité. Donc, le Guid pour définir l’identité ne suffit pas, car au final, le service Billing voit passer plusieurs requêtes. Dans la vraie vie, l’identité serait le code barre, donc, dans la vraie vie, le cas que je dépeins ne se produirait pas. Mais il est difficile de trouver des exemples parlants…  😉

Dans un système implémentant des commandes au sein d’une architecture à consistance éventuelle, implémenter l’idempotence permet une reprise sur erreurs “de masse” intéressante. Lorsqu’un producteur émet des notifications qui ont cessé d’être perçues par leurs consommateurs pendant la nuit, le producteur peut alors republier “commodément” un large lot de commandes (autant de fois qu’il est nécessaire) sans risquer d’altérer l’état du système suite à des prises en compte multiples. Si on décide d’émettre à nouveau des messages non captés, on obtient la séquence suivante :

{"$type":"Idempotence.PackageHasBeenShippedEvent, Idempotence","BarCode":{"$type":"Idempotence.BarCode, Idempotence"},"Identity":"27d1602f-78ab-4766-a645-468f2d3d702b","CreationDate":"2017-05-31T16:52:15.3588417+02:00","MacAddress":{"$type":"System.Net.NetworkInformation.PhysicalAddress, System"},"User":"venantvr","Done":false}

{"$type":"Idempotence.StatusHasBeenChangedEvent, Idempotence","BarCode":{"$type":"Idempotence.BarCode, Idempotence"},"Identity":"70ba339f-3914-4926-92ea-227d4c418d13","CreationDate":"2017-05-31T16:52:30.0233235+02:00","MacAddress":{"$type":"System.Net.NetworkInformation.PhysicalAddress, System"},"User":"venantvr","Done":false}

{"$type":"Idempotence.PackageHasBeenShippedEvent, Idempotence","BarCode":{"$type":"Idempotence.BarCode, Idempotence"},"Identity":"27d1602f-78ab-4766-a645-468f2d3d702b","CreationDate":"2017-05-31T16:52:15.3588417+02:00","MacAddress":{"$type":"System.Net.NetworkInformation.PhysicalAddress, System"},"User":"venantvr","Done":false}

{"$type":"Idempotence.StatusHasBeenChangedEvent, Idempotence","BarCode":{"$type":"Idempotence.BarCode, Idempotence"},"Identity":"67d2748b-90b3-42c3-b8cc-15a876a35944","CreationDate":"2017-05-31T16:52:31.0820326+02:00","MacAddress":{"$type":"System.Net.NetworkInformation.PhysicalAddress, System"},"User":"venantvr","Done":false}

Un premier accusé de complétion de la première commande accompagne normalement la demande initiale. Un second accusé de complétion (issu de la seconde exécution et fraîchement horodaté) vient compléter la liste. La séquence fait bien apparaître que, même si l’événement originateur est unique de par son identité, 2 événements enfants StatusHasBeenChangedEvent (différents) ont été émis. Cela est normal car l’identité est raccrochée au contexte d’exécution, plus qu’aux propriétés intrinsèques du message. En rejouant une séquence, nous pouvons donc avoir “par rebonds” autant de notifications filles que de reprises. C’est très dangereux. Même si les 2 notifications ont des identifiants différents, il est nécessaire de pouvoir dédoublonner ces commandes. La façon la plus efficace est cette fois-ci d’exploiter le seul lien pouvant rapprocher les 2 notifications. Ce lien est l’identifiant de la commande originatrice de l’ensemble du processus…

Add a Comment

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

8 + 2 =