L6.2 : garde de generation contre les ecritures post-invalidation

Un fetch parti AVANT une invalidation (clearCache/invalidateCache, declenchees
par onSwitch a la bascule de profil, D8) terminait APRES elle et ecrivait quand
meme son resultat : le cache repartait peuple avec les donnees de l'ancien
tenant, timestamp neuf, valid: true. Defaut latent avant L6.1 ; deterministe
apres, puisque la promesse en vol survit desormais a l'invalidation.

Compteur de generation par service, incremente a chaque invalidation. Le fetch
capture la generation au depart et, au moment de publier, jette son resultat si
elle a bouge. Surtout : les fonctions de chargement n'ecrivent plus rien en
cache — la publication est un `commit` passe a singleFlight.run, appele
seulement si la generation n'a pas change. La garde devient structurelle, pas
conventionnelle : un chargement ne peut plus publier par inadvertance.

L'invalidation vide aussi la Map des promesses en vol. L'appelant deja en
attente recoit quand meme son resultat — il l'a demande avant la bascule ;
c'est sa mise en cache qui est refusee.

--- Verifications (LIMAGRAIN) ---

Rafale [search_workflows(EasyWMS), switch_wms_profile(EUROTRAFIC)] envoyee d'un
bloc, puis get_application_summary SEQUENTIEL apres les deux reponses.

AVANT la garde (HEAD = L6.1), 3 rejeux — le cache survit a la bascule :

  ===== RUN 1 =====
    search_workflows : success=true count=50
    switch_wms_profile : success=true
    get_application_summary -> workflowCachesByApplication =
      {"EasyWMS":{"cached":true,"count":3944,"timestamp":1787665677594,
                  "age":0,"valid":true}}
                              adElementsByApplication = {}
    => caches peuples : 1  (attendu 0)
  ===== RUN 2 =====  idem, count 3944, timestamp 1787665687744, valid true
  ===== RUN 3 =====  idem, count 3944, timestamp 1787665703945, valid true

APRES la garde, 3 rejeux — aucun cache peuple :

  ===== RUN 1 =====
    search_workflows : success=true count=50
    switch_wms_profile : success=true
    get_application_summary -> workflowCachesByApplication = {}
                              adElementsByApplication      = {}
    => caches peuples : 0  (attendu 0)
    -- stderr : 1 resultat(s) jete(s) | 2 'Cache cleared'
  ===== RUN 2 =====  identique : caches peuples 0, 1 resultat jete
  ===== RUN 3 =====  identique : caches peuples 0, 1 resultat jete

Test direct et deterministe (invalidation declenchee pendant un fetch tenu
ouvert par une porte) :

  [Workflow] Cache expired or empty for "TestApp", fetching from API...
  --- invalidation PENDANT le fetch (clearCache) ---
  [Workflow] Cache cleared
  [Workflow] Fetched 1 workflows (total: 1)
  [Workflow] Result for "workflows::TestApp" discarded, not cached:
             cache invalidated during fetch (generation 0 -> 1)
    l'appelant recoit bien son resultat : 1 workflow(s)
    cache apres coup : {}  (attendu {})

Non-regression L6.1, 3 rejeux de la rafale de 6 (CustomApp) :

  == RUN 1 : 6 reponses a 44 | fetch=1 joins=5 caches=1
  == RUN 2 : 6 reponses a 44 | fetch=1 joins=5 caches=1
  == RUN 3 : 6 reponses a 44 | fetch=1 joins=5 caches=1

Chemin sequentiel nominal, inchange :

  [search_workflows] success=true application=EasyWMS count=50 len=14220
  [search_workflows] success=true application=EasyWMS count=50 len=14220
    fetch=1 cached=1 joins=0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Arthur Ria
2026-08-25 15:49:39 +02:00
parent b7151b3bc7
commit 097b76c7ef
7 changed files with 137 additions and 55 deletions
+24 -1
View File
@@ -648,7 +648,7 @@ passé), et ajoutent un `hint` quand la recherche revient **vide** :
---
## D27 — Chargements paresseux sous concurrence : single-flight par clé
## D27 — Chargements paresseux sous concurrence : single-flight + génération
**Contexte (mesuré le 25/08/2026, `LIMAGRAI2512`).** Le serveur traite les
`tools/call` **en concurrence** : une rafale d'appels dans une même session
@@ -684,6 +684,29 @@ pas une dépendance externe :
fusionnez pas les deux, c'est ce qui rend la déduplication vérifiable depuis
stderr.
**Garde de génération.** Un fetch parti *avant* une invalidation terminait
*après* elle et écrivait quand même son résultat : le cache repartait peuplé
avec les données de l'ancien tenant, timestamp neuf, `valid: true`. Défaut
latent avant le single-flight, **déterministe après** (la promesse en vol
survit à l'invalidation). D'où :
- Un **compteur de génération par service**, incrémenté à chaque invalidation
(`clearCache()` / `invalidateCache()`, toujours déclenchées par
`onSwitch()` — D8 inchangé). Le fetch capture la génération au départ.
- **Les fonctions de chargement n'écrivent plus rien en cache** : la
publication est un `commit` passé à `singleFlight.run`, appelé *seulement*
si la génération n'a pas bougé. C'est structurel, pas conventionnel — un
fetch ne peut plus publier par inadvertance.
- L'invalidation vide aussi la Map des promesses en vol. **L'appelant reçoit
quand même son résultat** — il l'a demandé avant la bascule ; c'est sa mise
en cache qui est refusée, tracée par
`Result for "…" discarded, not cached`.
Mesuré sur `[search_workflows(EasyWMS), switch_wms_profile(EUROTRAFIC)]` envoyé
d'un bloc, puis `get_application_summary` en séquentiel : **3/3 avant**, le
cache EasyWMS de LIMAGRAIN (3 944 workflows) survit à la bascule avec un
timestamp neuf ; **3/3 après**, aucun cache peuplé.
**Mesures après.** Rafale de 6 (CustomApp) → 1 fetch + 5 joins, les 6 réponses
à `count: 44`. Rafale de 6 (resolver) → 1 chargement, 5 GET Metadata au lieu de
30. Rafale mixte EasyWMS + CustomApp → **un fetch par application**, deux au