Deux mémoires
Un processus qui s'arrête n'efface pas ce qu'il a terminé. Il efface seulement ce qu'il n'a pas encore écrit ailleurs.
Le DAG ordonne l'exécution. Le mécanisme de retry absorbe les défaillances transitoires. Or il reste un cas que ni l'un ni l'autre ne couvre : le processus lui-même qui s'arrête. Un pod qui redémarre, une instance qui sature, un signal d'arrêt qui arrive pendant qu'une règle tourne. L'état en mémoire disparaît. Ce qui a été accompli, ce qui était en cours — tout cela vit dans le processus, et meurt avec lui.
La résilience face à ce cas repose sur un principe simple : distribuer l'état entre deux mémoires qui survivent chacune à sa façon. J'ai mis un moment à comprendre que ces deux mémoires ne se redondent pas — elles se complètent. Supprimer l'une des deux ne renforce pas l'autre ; ça crée un angle mort.
Le message qui attend
La première mémoire n'appartient pas à AutoEval. Elle appartient à RabbitMQ.
Quand EvaluationController reçoit un message evaluation.execute, il ne l'acquitte pas immédiatement. Il attend d'abord d'avoir récupéré la configuration via NATS, d'avoir initialisé le job, d'avoir émis evaluation.started. Ce n'est qu'à ce moment que l'ACK est envoyé — une confirmation que le travail a effectivement commencé.
La conséquence est précise : tant que l'ACK n'est pas envoyé, RabbitMQ conserve le message. Si le pod s'arrête avant d'avoir pu ACKer, le message est relivré. L'évaluation recommence depuis le début, comme si rien ne s'était passé.
Or si le crash survient après l'ACK — pendant l'exécution des règles — RabbitMQ ne sait plus rien. Le message est acquitté, considéré comme traité. La relivraison automatique ne se déclenche pas. C'est là qu'intervient la seconde mémoire.
L'état dans Redis
La seconde mémoire est Redis Sentinel — déjà dans l'infrastructure, déjà utilisé pour le cache du VectorStore. Chaque r ègle complétée avec succès y écrit immédiatement son résultat. Chaque règle en cours de retry y écrit son statut. La structure est simple :
evaluation:{evaluationId}:rule:{ruleId}:result → RuleResult JSON (règle terminée)
evaluation:{evaluationId}:rule:{ruleId}:status → failed_attempt_N (règle en retry)
Un TTL de sept jours garantit que ces clés s'effacent automatiquement — sans intervention, sans nettoyage manuel.
La granularité est au niveau de la règle, pas de la passe. Ce n'est pas une coïncidence : le DAG fonctionne à la granularité des règles, le mécanisme de retry fonctionne à la granularité des règles. La recovery applique la même cohérence. Si neuf règles sur dix ont terminé avant le crash, une seule doit être rejouée. Redis le sait — et ReEvaluateUseCase ne demande pas plus que ce que Redis peut dire.
Pourquoi Redis plutôt qu'un système de fichiers ? La question mérite d'être posée directement. Un fichier JSON par règle aurait fonctionné — c'est lisible, c'est simple, ça ne nécessite pas de réseau. Or cela aurait introduit une dépendance d'infrastructure supplémentaire : des volumes persistants montés sur chaque pod, dimensionnés, sauvegardés, réconciliés quand un pod reprend le travail d'un autre. Redis Sentinel, lui, est déjà là. Il est accessible depuis n'importe quelle instance, sans montage de volume. Ses écritures sont atomiques — pas de risque d'état partiel en cas d'interruption au milieu d'une écriture. Et la dépendance existe déjà : si Redis est indisponible, le VectorStore cache ne fonctionne plus, et les agents ne peuvent plus lire le code du candidat. La recovery via Redis n'ajoute pas un nouveau point de défaillance — elle s'inscrit dans une dépendance déjà contractée.
Ce que ReEvaluateUseCase reconstruit
Le message est relivré. ReEvaluateUseCase commence par une lecture Redis : il récupère tous les états connus pour cet evaluationId. Trois catégories émergent.
Les règles dont le résultat est présent dans Redis sont terminées. ReEvaluateUseCase les charge directement — leurs RuleResult sont intégrés dans le plan sans relancer AgentService. Les passes dont toutes les règles sont terminées de cette façon sont complètes, sans qu'une seule règle soit réexécutée.
Les règles dont le statut est failed_attempt_N sont en cours de retry. Elles sont réintégrées dans le plan d'exécution avec leur compte de tentatives — le backoff exponentiel reprend là où il s'était arrêté.
Les règles sans aucune entrée Redis n'ont jamais été exécutées, ou leur résultat n'a pas eu le temps d'être écrit avant le crash. Elles démarrent depuis zéro.
Ce que ReEvaluateUseCase reconstruit, c'est le DAG dans son état au moment du crash — non pas depuis la mémoire du processus mort, mais depuis la projection fidèle qu'en a conservé Redis.
Ce que les deux mémoires garantissent ensemble
RabbitMQ garantit que le message arrive. Il ne garantit pas que le travail soit fait.
Redis garantit que ce qui a été fait n'est pas refait. Il ne garantit pas que le message revient.
Aucune des deux mémoires ne suffit seule. RabbitMQ sans Redis : la relivraison déclenche une exécution depuis zéro — toutes les règles déjà terminées sont rejouées, leur coût LLM est dupliqué. Redis sans RabbitMQ : l'état est conservé, mais personne ne sait qu'il faut reprendre l'exécution.
Il reste une limite à nommer. Si le crash survient dans la fenêtre entre la complétion d'une règle et son écriture dans Redis — quelques millisecondes — cette règle sera rejouée. C'est la sémantique d'at-least-once : préférable à l'absence de résultat, mais qui implique que les règles doivent être idempotentes. Une règle exécutée deux fois sur le même code doit produire le même verdict. C'est une contrainte de conception sur AgentService — non sur l'infrastructure. Ce qui est, dans un sens, rassurant : cela signifie qu'un agent qui évalue correctement le même code deux fois de suite est simplement... un agent fiable.
La prochaine fois : EvaluationModule est refermé — son cycle de vie, son DAG, sa résilience. Ce qui reste, c'est ce qui le flanque : VectorStoreModule, le cache partagé entre tous les agents, et la question de ce qui rend une collection Qdrant cohérente entre plusieurs évaluations simultanées.