0040 — Caches applicatifs : flux + backing-service injectable vs fichier local
Contexte
Section intitulée « Contexte »L’audit cloud-native du 2026-06-04 relève deux écarts qui se rejoignent sur le même mécanisme — les caches applicatifs persistés en fichiers JSON locaux :
- Facteur VIII (Concurrency). Plusieurs caches sont des fichiers JSON écrits sur le disque local, sans verrou. Hors d’un déploiement mono-instance, deux processus qui rafraîchissent le même cache se marchent dessus (lecture/écriture concourantes, dernier écrivain gagne, fichier corrompu).
- Facteur XI (Logs).
crf-logspersiste des données dérivées de logs dans un fichier (.crf-stats.json) au lieu de traiter le flux à la volée.
Le code montre deux états de maturité différents face à ce problème :
packages/crf-logs/src/cache.tscode le chemin en dur :const CACHE_PATH = path.resolve(process.cwd(), ".crf-stats.json"), avec un TTL de 24 h (isCacheStalecompareDate.now() - cache.savedAtàCACHE_TTL_MS). Le chemin n’est pas injectable : il dépend du répertoire de travail du processus. En multi-instance, chaque instance écrit son propre fichier (caches divergents) ou, si elles partagent un volume, se corrompent mutuellement.packages/atlas-stats/src/cache.tsa déjà l’indirection attendue :resolveCachePath()lit la variable d’environnementATLAS_STATS_CACHE_PATH(avecpath.resolveet garde sur la chaîne vide) et retombe sur la racine du workspace (resolveWorkspaceRoot, marqueurpnpm-workspace.yaml) sinon. Le point d’injection par variable d’environnement existe déjà ; seul le back-end reste un fichier.
Le pattern de l’indirection est donc acquis sur un paquet, absent sur l’autre. Il faut trancher une posture commune avant que d’autres caches ne soient écrits sur le même moule du fichier en dur.
Périmètre : ce dépôt de code — quels back-ends de cache le code doit supporter et comment il les sélectionne. Le dimensionnement de l’infra (quel Redis, quelle base, quel cluster) relève du contrat d’interface avec le cluster (ADR 0033), hors de cet ADR.
Décision
Section intitulée « Décision »Un cache applicatif n’est pas un fichier JSON local. C’est un backing service injecté par variable d’environnement, avec un back-end choisi à l’exécution selon l’environnement.
Le cache est un backing service, pas un état local du processus
Section intitulée « Le cache est un backing service, pas un état local du processus »On aligne les caches sur le facteur IV (backing services) déjà appliqué pour le CRF, le BaaS et la télémétrie : une ressource externe attachée par URL + credentials en variables d’environnement, et non un fichier lié au système de fichiers du processus. Le processus reste stateless (facteur VI) : son cache vit en dehors de lui et survit à son redémarrage comme à sa réplication.
Le back-end est sélectionné à l’exécution, pas codé en dur
Section intitulée « Le back-end est sélectionné à l’exécution, pas codé en dur »Le code expose une interface de cache (readCache / writeCache /
isCacheStale, signatures déjà présentes dans les deux paquets) et choisit son
implémentation à partir de l’environnement :
- dev — implémentation in-memory (Map en mémoire de processus), zéro dépendance, jetable au redémarrage ;
- test — implémentation mock déterministe (état injecté par le test, aucune écriture disque, aucun TTL horloge-réelle) ;
- prod — backing service partagé : un cache clé-valeur en mémoire distribuée, ou la base relationnelle déjà présente comme stockage de l’application. Le choix prod-précis relève de l’infra ; le code doit supporter au moins l’un des deux derrière la même interface.
La sélection passe par une variable d’environnement (sur le modèle déjà en
place : ATLAS_STATS_CACHE_PATH côté atlas-stats). On généralise ce point
d’injection : la variable ne désigne plus un chemin de fichier mais une
ressource (back-end + sa connexion). Le fallback fichier reste toléré
uniquement en local mono-instance, jamais comme cible de production.
crf-logs acquiert l’indirection, et traite ses logs en flux
Section intitulée « crf-logs acquiert l’indirection, et traite ses logs en flux »packages/crf-logs/src/cache.tsdoit abandonnerCACHE_PATHen dur et passer par le même point d’injection queatlas-stats. C’est le minimum pour fermer l’écart du facteur VIII.- Au-delà du cache,
crf-logsdoit traiter les logs en flux plutôt que de les persister dans.crf-stats.json(facteur XI) : la source de vérité des logs est le flux stdout, le cache n’est qu’une vue dérivée et reconstructible, jamais le stockage primaire.
Garde-fous de cohérence en multi-instance
Section intitulée « Garde-fous de cohérence en multi-instance »Tout cache de production doit assumer la concurrence (facteur VIII) : accès atomiques (le backing service garantit l’atomicité, pas le code applicatif), TTL porté par le back-end quand il le permet, et aucune hypothèse mono-instance dans le code. Un cache qui ne tolère pas deux écrivains simultanés n’est pas prod-ready.
Évolution (2026-06-04) — Préparer l’indirection sans déployer l’infra
Section intitulée « Évolution (2026-06-04) — Préparer l’indirection sans déployer l’infra »Ce dépôt étant un dépôt de code, la part exécutable de #306 (sûreté en concurrence) se limite à préparer les points d’injection que le futur déployeur câblera sur un backing service partagé — sans embarquer ici de Redis ni de base. Trois acquis concrets :
- Écriture atomique du cache
atlas-stats.writeCachen’écrit plus directement sur le fichier cible : il écrit dans un temporaire propre au processus (<cible>.<pid>.tmp) puis fait unrename— atomique au niveau du système de fichiers. Un lecteur ne voit jamais un fichier à moitié écrit, et deux écrivains concourants ne se corrompent plus mutuellement (dernierrenamegagne, sans état intermédiaire visible). C’est la sûreté en concurrence atteignable sans backing service, en attendant l’atomicité native d’un cache réseau. - Coordination d’actualisation injectable (dashboard). La déduplication des
actualisations en vol et le bridage de cadence vivaient en variables de
module dans l’endpoint SSE
/api/refresh— un état correct en mono-instance seulement. Ils sont désormais derrière l’interfaceRefreshCoordinator(apps/atlas-dashboard/src/lib/refresh-coordinator.ts), avec une implémentation in-memory par défaut reproduisant le comportement historique, et un point d’injection : un déploiement multi-instance fournira une implémentation adossée à un verrou distribué + une clé d’horodatage partagée, sans toucher à l’endpoint. crf-logsrendu injectable. LeCACHE_PATHen dur a cédé la place àresolveCachePath()lisantCRF_LOGS_CACHE_PATHavec fallback racine de workspace, alignant le paquet suratlas-stats(#305 pour la mise en flux reste à faire).
Le branchement effectif d’un backing service partagé (et son test en conditions réelles) relève du dépôt cluster, pas de celui-ci.
Évolution (2026-06-29) — Le back-end Postgres réel, derrière un paquet partagé
Section intitulée « Évolution (2026-06-29) — Le back-end Postgres réel, derrière un paquet partagé »Le branchement annoncé est livré. Le cluster a fourni l’infra
(ADR cluster 0093 :
base logique cache sur le CloudNativePG existant, rôle, Secret, variables
POSTGRES_CACHE_*), et atlas ajoute l’adaptateur — c’est l’objet de
l’ADR 0085. Quatre points
prolongent la posture posée ici, sans la contredire :
- Le back-end Postgres existe (4ᵉ acquis après l’écriture atomique, le
RefreshCoordinatoret l’indirectioncrf-logs) : tablecache(key, value JSONB, saved_at), UPSERT atomiqueON CONFLICT,pg_advisory_lockpour la déduplication — l’atomicité native attendue ci-dessus, fournie par le back-end et non par le code. - La sélection par DSN concrétise « la variable désigne une ressource, pas un
chemin » : une valeur
postgres://…arme le back-end Postgres ; sinon, fichier inchangé. Toujours explicite, jamais magique. - Le TTL passe côté
saved_at: le back-end horodate,isCacheStalejuge sur lesaved_atrelu — une seule source, pas de double-vérité. - L’interface est extraite dans un paquet partagé
@univ-lehavre/atlas-cache(Effect), consommé paratlas-statsetcrf-logs: fin des deux implémentations parallèles. Détail et arbitrages dans l’ADR 0085.
Requalification (2026-06-30) — crf-logs est un cache, pas un journal applicatif (facteur XI)
Section intitulée « Requalification (2026-06-30) — crf-logs est un cache, pas un journal applicatif (facteur XI) »Le Contexte ci-dessus invoquait le facteur XI (Logs) pour crf-logs : son
nom et l’audit cloud-native du 2026-06-04 laissaient croire à un journal
applicatif persisté en fichier au lieu d’un flux stdout. C’était une erreur de
qualification, levée à la lecture du code (#305).
crf-logs ne journalise rien du processus atlas. Il récupère par HTTP
les audit logs d’une plateforme REDCap externe (fetchProjectLogs →
{ project_id, username, action, timestamp }) et les agrège en analytics à
fenêtre glissante (description du paquet : « CRF audit log fetching, enrichment and
rolling-window analytics »). C’est de la donnée métier externe mise en cache —
exactement le facteur IV/VIII (backing service, concurrence), déjà traité ici
et parachevé par le back-end Postgres (#443/ADR 0085).
Le facteur XI ne s’applique donc pas à crf-logs : émettre ces données sur
stdout serait un anti-pattern (déverser de la donnée métier dans le flux de logs du
conteneur). La sous-section « crf-logs acquiert l’indirection, et traite ses logs
en flux » ci-dessus est à lire uniquement pour sa première moitié (l’indirection
du cache, faite) ; la seconde moitié (« traiter les logs en flux ») tombe — il
n’y a pas de logs applicatifs à mettre en flux ici. La ligne « XI. Logs » des
Normes passe de Partiel/écart à Appliqué.
Accepted (2026-06-04). Étendu le 2026-06-29 (section Évolution) : back-end
Postgres réel + paquet partagé, acté par
l’ADR 0085.
Requalifié le 2026-06-30 (section ci-dessus) : crf-logs est un cache de données
externes, hors facteur XI (#305).
Conséquences
Section intitulée « Conséquences »Bénéfices.
- L’écart VIII (Concurrency) de l’audit se ferme : plus de fichier JSON local sans verrou, un cache partagé et atomique en multi-instance.
- L’écart XI (Logs) se ferme côté
crf-logs: flux comme source de vérité, cache comme vue dérivée jetable. - Le processus devient réellement stateless (VI) : il peut être répliqué ou redémarré sans perdre — ni corrompre — son cache.
- Le point d’injection existe déjà (
ATLAS_STATS_CACHE_PATH) : on généralise un acquis plutôt que d’inventer un mécanisme, etcrf-logss’aligne sur un paquet voisin. - dev / test / prod partagent la même interface, ce qui rapproche l’environnement de dev de la prod (facteur X) sans imposer un backing service lourd en local.
Prix à payer.
packages/crf-logs/src/cache.tsdoit être réécrit :CACHE_PATHen dur disparaît, et la persistance fichier des logs cède la place au traitement en flux. C’est un changement de contrat interne, pas un simple paramétrage.- La prod gagne une dépendance d’infra (cache distribué ou base) là où un fichier suffisait apparemment — coût réel, mais c’est le prix d’un déploiement multi-instance correct.
- Trois implémentations à maintenir derrière l’interface (in-memory, mock, backing service), au lieu d’une seule lecture/écriture fichier.
Garde-fous.
- Le fallback fichier reste autorisé en local mono-instance uniquement (DX, démos), jamais en production : la variable d’environnement pointe alors vers un vrai backing service.
- La sélection du back-end est explicite par variable d’environnement — pas de détection magique : un environnement non configuré tombe sur l’implémentation in-memory (dev), jamais silencieusement sur un fichier partagé.
- Le dimensionnement de l’infra de cache reste hors de cet ADR : il relève du contrat d’interface avec le cluster (ADR 0033).
- Suivi opérationnel : la mise en flux de
crf-logs(logs) et la sûreté en concurrence (caches) sont les deux chantiers issus de l’audit (#305 — logs en flux ; #306 — concurrence) ; cet ADR fixe la posture, les issues portent l’exécution (cf. cadence d’audit, ADR 0039).