mardi 5 janvier 2021

Comment éviter un piège avec les systèmes "EventSourced"


Dans ce type de système apparaît un problématique pour laquelle nous sommes mal armées car elle n'existe pas dans des systèmes traditionnels. Certes il n'est pas compliqué de l'éviter si on le connaît, mais il est possible de le découvrir tard et ainsi de payer cher la correction.

TLDR; l'état perceptible d'un système est un cache. Quand on vide ce cache pour le re-calculer, il va être calculé avec la dernière version du code et donc le résultat pourrait être différent. Pour s'en prémunir il y a une invariante à respecter

Quelques bases pour comprendre

Pour comprendre la problématique, revoyons d'abord les bases de l'approche EventSourcing. Visuellement, ca va être simple. Si jamais mon résumé en 10 lignes ne suffit pas, il a y a ceci.

Quand une commande arrive par le biais d'un utilisateur ou d'une autre application, elle se fait transformer en un event (ou plusieurs) qui est persisté. Ces évènements sont utilisés pour calculer l'état (state) actuel du système. La fonction decide transforme la commande en event et la fonction evolve transforme un event en état qui est incrémenté avec chaque nouvel évènement. Certes les deux fonctions ont aussi besoin  de l'état actuel pour ce calcul, mais j'ai omis ce détail dans le dessin. 





On pourrait faire sans l'état mais pour des raisons de performance on fait un (ou plusieurs) états. A n'importe quel moment cet état peut être réinitialisé, c'est pas grave on sait tout recalculer à partir des évents. 

C'est ce recalcul qui présente un piège de temporalité.

Le piège

De temps en temps on livre de nouvelles versions de l'application. En particulier la fonction evolve, comme ceci



Ici les deux premières itérations d'état ont été calculées avec la première version d'evolve et les deux derniers avec evolve'. Dans le cas  d'un recalcul c'est différent car tous les évènements sont processés avec evolve'.  Si la dernière version de evolve traite un évent différemment alors il y a une différence, on n'a plus s4 comme état final mais s4'.



Mais quel est le problème? C'est pas ce qu'on veut? Eh ben pas toujours. Cela pourrait être un bug. Imaginons qu'on calcule la taxe sur des évènements d'achat et qu'à un moment donné la taxe a changé. On veut que ce calcul donne le même résultat qu'il soit fait maintenant ou au fil de l'eau. L'utilisateur ne doit pas être impacté par quand on a calculé l'état. Imaginons l'utilisateur qui a travaillé dur pour attendre un niveau sur StackOverflow. Il serait bien content s'il perd des points suite à une livraison.

L'invariant à respecter

Une nouvelle version de la fonction evolve doit toujours donner le même résultat pour un événement donné.

Si on choisit de le respecter, les états, les caches du système, sont toujours cohérents et n'ont pas besoin d'être recalculés. Par contre dans le cas d'un bug dans la fonction evolve on aurait un état incorrect, alors il est probable qu'on souhaite recalculer l'état pour avoir un résultat différent justement. Dans les deux cas c'est un choix intentionnel, le pire serait de ne pas avoir les idées claires sur ce mécanisme et de se retrouver surpris devant des changements d'état d'apparence aléatoires.

Mais comment fait-on pour résoudre ce dilemme de ne pas pouvoir changer le comportement actuel, tout en introduisant de nouvelles fonctionnalités ou des changements de règles métier? 

Les solutions

La seule axe de solution, à ma connaissance, se trouve dans la fonction decide qui décide quel événement créer suite à la commande. Elle peut changer, une même commande qui arrive maintenant n'est pas obligé de produire le même événement. Pourquoi donc? Simplement parce que la fonction decide s'exécute toujours au moment de l'arrivée de la commande, jamais de façon rétroactive. Cette fonction décide comment interpréter la commande en créant un événement, qui est persisté. A partir de là je connais trois approches, créer un nouveau type d'évènement, créer un nouvelle version ou se baser sur la date.

Un nouveau type d'événement

Si on change decide pour émettre une nouvelle version de l'événement alors evolve peut le traiter en cas "special". Puisque ce évènement n'existait pas avant alors on a pas cassé l'invariant. Par exemple pour un achat à 5% de TVA au lieu de 20%, au lieu d'émettre Purchased on peut émettre PurchasedVAT5-5. Toute la nouvelle logique se trouve dans le traitement de ce nouvel événement. On a pas touché à l'ancien car pour un évènement Purchase on applique toujours l'ancien taux.

Evolve reste ouvert à l'extension de nouveaux types, mais fermé à la modification sur les types existantes

Une nouvelle version d'un event existant

Si on a plus besoin de l'événement d'avant. Genre il n'existe plus de TVA à 20% alors on peut utiliser l'ancienne type. Le fait d'annoter l'événement avec une version +1 permet d'appliquer un traitement différent et donc préserver l'invariant. 

Se baser sur la date

Une autre possibilité est de se baser sur la date. L'invariant nous interdit de changer l'algo pour un même évènement, mais finalement les évènements récentes ont une date différente. Donc il suffit de préserver le fonctionnement pour les évènements antérieures à une certaine date.

Utiliser une date semble intéressant car c'est légèrement plus facile et c'est peut-être justifié dans certains cas. L'inconvénient est que dans cette approche le code n'exprime pas bien le concept métier qui a changé. La règle métier sera enfouie dans un simple if quelque part. Alors qu'en introduisant un nouveau type cela devient plus visible dans la structure du code, même dans la base de données. Avec un nouveau type, le code communique mieux le métier.

Conclusion

Pour un changement de fonctionnalité, ajouter un nouvel type/variante d'évènement en changeant decide. Étendre la fonction evolve pour le traiter, sans changer l'existant.

Une façon de résumer est de dire que 

Tant qu'une nouvelle version de evolve est une extension de la version précédente alors on n'a pas besoin de recalculer les caches du système 

accessoirement on est aussi certain de ne pas surprendre l'utilisateur - il verra toujours un état cohérent avec le passée.

Bref, c'est un piège qui peut causer bien des maux de tête, mais il est facile de s'en prémunir, dès lors que l'on sait s'en méfier.

Merci à Jérémie Chassaing de m'avoir expliqué le problème, et à Arnaud Bailly pour son aide. 

Aucun commentaire:

Publier un commentaire