Définir dynamiquement des callbacks sur les modèles ActiveRecord

J'ai récemment eu l'occasion de me creuser les méninges sur un cas un peu particulier à gérer au sein d'une application Rails : l'exécution de tâches asynchrones, initiées au cours d'une transaction d'insertion de données, et dépendant de données dont l'insertion en base de données n'est effective qu'une fois le commit de la transaction réalisée.

Voici un rapide retour d'expérience pour vous partager mon approche face à cette problématique de conception, et une proposition de résolution dans l'écosystème Ruby on Rails.

Le contexte : machine à état, callbacks asynchrones et transactions

J'ai dans mon application certains modèles affublés d'une machine à état sur laquelle se greffent certains callbacks dont la noble mission est de déclencher des tâches asynchrones, typiquement un envoi d'email géré par un ActiveJob.

Au cours de certaines requêtes, plusieurs entités, de différents modèles, liées entre elles, peuvent être concernées par les modifications à persister, et comme potentiellement quelque chose peut mal se passer au cours de la modification d'une de ces entités (e.g. une contrainte de validité non respectée), le parti a été pris de parfois enrober l'ensemble de ces interactions avec notre couche de données dans une transaction, ce qui permet ainsi d'avoir un rollback sur l'ensemble des modifications si l'une d'entre elles n'était pas possible.
Ce pattern nous a permis de maintenir assez simplement une cohérence entre nos différentes données, mais de celui-ci un problème a émergé : l'un de nos modèles transite d'un état à un autre en fonctions des modifications qui lui sont apportées, et déclenche l'envoi asynchrone d'un email dont la création nécessite l'accès à des données créées au cours de la requête courante, dans un process différent car Job asynchrone, mais ces données n'ayant pas encore été persistées en base de données, la transaction n'ayant pas encore été validée, le tout explose :boom:.

Une fois l'origine de cette explosion identifiée, le constat fut assez simple : mes tâches asynchrones, à savoir ici l'envoi d'email, doivent être lancée une fois ma transaction terminée, donc à la suite du commit de mon entité en base de données.

Le hook ActiveRecord qui pourrait aller bien : after_commit

ActiveRecord fournit un ensemble de hooks sur mes modèles, permettant de définir un certain nombre de callbacks en fonction de divers cas (cf. Active Record Callbacks).
Le hook after_commit semble alors être la solution adéquate afin de juguler notre problème.
Mais si je définis de cette manière un callback, il sera exécuté à chaque fois que je persiste mon entité, ce que je ne souhaite pas : mon envoi d'email ne doit s'en faire que dans un cas précis, lors d'une transition bien particulière de ma machine à état.

Il faut donc que je sois en mesure de définir dynamiquement, c'est-à-dire au cours de l'exécution de mon application, un callback pour envoyer mon email.
Malheureusement, Ruby on Rails et ActiveRecord ne permettent pas cela out-of-the-box.

Une stack non persistente de callbacks

En dépit total devant ce problème de conception pour lequel mon framework préféré ne me propose aucune solution magique, je me sentais vaciller au bord d'un gouffre sans fond lorsque soudain me vient un éclair de génie 💡 : armer mon modèle d'une stack non persistente dans laquelle je vais pouvoir enquiller autant de callbacks que nécessaire, et les exécuter après le commit des modifications de mon entité en base de données grâce au hook after_commit.

En jouant de l'aspect fonctionnel de Ruby, avec un Proc ou une Lamba, permet de facilement définir et manipuler des blocs de code à exécuter quand bon me semble.
On peut même y passer autant d'arguments qu'on le souhaite.
Ne reste plus qu'à les stocker dynamiquement dans une liste, attribut non persistent de mon entité, et les jouer une fois le commit de mon entité réalisé.

J'ai choisi de mettre tout cela dans un Concern, afin d'importer facilement cette mécanique dans les différents modèles où je souhaite l'utiliser.
Finalement, le tout tient en une vingtaine de lignes :

Notez le args = [self].push(*cb_with_args.drop(1)) qui permet d'ajouter en tête de la liste de paramètres passée au callback le self qui va référencer l'entité courante sur laquelle le callback aura été enregistré.
On s'assure également d'attraper toute erreur qui pourrait être provoquée par un callback foireux, histoire de ne pas tout péter.

Le hack en action

Pour conclure ce court article, voici une petite mise en circonstances de ce petit bidouillage.
Considérons une application Rails ayant un modèle User possédant des attributs :firstname et :lastname persistés en base de données.
On aura évidemment pris soin de déclarer dans /app/models/concerns/after_commit_callbacks.rb le code de notre AfterCommitCallbacksConcern

Il nous suffit alors d'ouvrir notre console pour tester l'ajout d'un callback :

On voit clairement ici l'ajout de mon nouvel User, puis l'exécution du callback fraîchement enregistré sur l'entité en question une fois le commit de ses attributs réalisé.
Bref, à priori, on est bien 👌

Aurélien Havet

Aurélien Havet

Lire d'autres articles par cet auteur.