Passation lot 6 : chargements paresseux sous concurrence (D27 réservée)

Le point ouvert concurrence est scindé : la manifestation caches devient
le lot 6 (L6.1 single-flight par clé, L6.2 garde de génération contre
les écritures post-invalidation, L6.3 refus d'encaisser un vide anormal
— response?.entities || [] transforme une réponse transitoirement
anormale en cache vide empoisonné pour tout le TTL, cause probable du
count 0 mesuré au lot 5). La manifestation bascule de profil reste en
point ouvert, hors périmètre du lot.

Preuves dans la passation : 6 chargements Metadata parallèles pour une
rafale de 6 appels (lot 2), count 0 contre 44 en séquentiel (lot 5),
avec la commande de rafale reproductible et l'exigence de rejouer
chaque vérification de concurrence trois fois.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Arthur Ria
2026-08-25 15:34:01 +02:00
parent 1851b38c62
commit 8a631f5ea4
2 changed files with 256 additions and 16 deletions
+43 -16
View File
@@ -82,6 +82,43 @@ plus riche que `/AD/api/Application/GetAll`), `GET /healthcheck?tenantCode=` et
--- ---
## Lot 6 — Chargements paresseux sous concurrence
Mesuré le 25/08/2026 (révisions des lots 2 et 5) : sans aucune bascule de
profil, une rafale d'appels concurrents pendant un chargement paresseux
produit des résultats faux en silence — `search_workflows("CST_",
application: "CustomApp")` a répondu `count: 0` (contre 44 en séquentiel), et
une rafale au lot 2 a déclenché **6 chargements Metadata complets en
parallèle** (6 × `[EntityResolver] Cache expired or empty, fetching…`).
Rejoués en séquentiel, les mêmes appels sont corrects.
Trois défauts structurels dans les services à cache (`workflow-service`,
`ad-service`, `entity-resolver`) :
### L6.1 — Dédupliquer les fetchs en vol
Aucun service ne mémorise la promesse de chargement en cours : N appelants
concurrents sur la même clé déclenchent N chaînes de fetch complètes.
Single-flight par clé de cache (promesse partagée, libérée au règlement).
### L6.2 — Protéger l'invalidation contre les fetchs en vol
Un fetch parti avant `clearCache()`/`invalidateCache()` (bascule de profil,
D8) écrit son résultat **après** l'invalidation : cache repeuplé avec les
données de l'ancien tenant. Garde de génération (epoch) : un résultat issu
d'une génération antérieure est jeté, pas écrit.
### L6.3 — Ne pas encaisser un vide anormal
`fetchAllWorkflows` fait `response?.entities || []` puis met en cache le
résultat même vide, avec un timestamp valide : toute réponse transitoirement
anormale (forme inattendue sous concurrence) devient un **cache vide
empoisonné pour tout le TTL** — cause probable du `count: 0` mesuré. Une
forme sans `entities` doit lever ; un `entities: []` réel reste cachable
(des applications légitimement vides existent, ex. `SmartUI`).
---
## Écarté ## Écarté
| Proposition | Raison | | Proposition | Raison |
@@ -105,23 +142,13 @@ plus riche que `/AD/api/Application/GetAll`), `GET /healthcheck?tenantCode=` et
test. test.
- **Bascule de profil concurrente aux appels en vol** (mesuré le 25/08/2026, - **Bascule de profil concurrente aux appels en vol** (mesuré le 25/08/2026,
révision du lot 2). Le serveur traite les `tools/call` **en concurrence** : révision du lot 2). Le serveur traite les `tools/call` **en concurrence** :
en envoyant une rafale de requêtes dans une même session, les réponses un `switch_wms_profile` émis pendant que des requêtes sont en vol les fait
reviennent dans le désordre, et un `switch_wms_profile` émis pendant que des partir sur le nouveau profil (observé : une requête destinée à EUROTRAFIC
requêtes sont en vol les fait partir sur le nouveau profil (observé : une exécutée sur l'hôte `10.255.255.2` après la bascule suivante). Conséquence du
requête destinée à EUROTRAFIC exécutée sur l'hôte `10.255.255.2` après la singleton d'état global (D8). À traiter si un cas réel de mélange de profils
bascule suivante). Conséquence du singleton d'état global (D8). Sans gravité
pour un usage conversationnel séquentiel, mais Claude peut émettre des appels
d'outils **en parallèle** : à traiter si un cas réel de mélange de profils
est observé (piste : sérialiser les `tools/call` ou figer le profil résolu au est observé (piste : sérialiser les `tools/call` ou figer le profil résolu au
début de chaque appel). **Seconde manifestation mesurée (25/08/2026, révision début de chaque appel). La manifestation « caches » de la même concurrence
du lot 5)** : sans aucune bascule de profil, une rafale d'appels concurrents est traitée par le **lot 6** ci-dessus.
pendant le chargement paresseux du cache workflow renvoie des résultats
faux en silence — `search_workflows("CST_", application: "CustomApp")` a
répondu `count: 0` (contre 44 en séquentiel), et `get_workflow_details` une
réponse anormale — les chargements concurrents du même cache ne sont pas
synchronisés (pas de déduplication de fetch en vol). Rejoués en séquentiel,
les mêmes appels sont corrects. Piste supplémentaire : mémoriser la promesse
de fetch en cours par clé de cache et la partager entre appelants.
- **`select_expression`** : les projections via le paramètre `Select` provoquent - **`select_expression`** : les projections via le paramètre `Select` provoquent
des erreurs de compilation côté serveur (D13). Irritant principal restant. des erreurs de compilation côté serveur (D13). Irritant principal restant.
- **Déploiement SSH sur la VM** : l'exécutable est validé, la configuration SSH - **Déploiement SSH sur la VM** : l'exécutable est validé, la configuration SSH
+213
View File
@@ -0,0 +1,213 @@
# Passation — lot 6 (chargements paresseux sous concurrence)
Tu travailles sur `mcp-wms-api` : un serveur MCP (Node.js, CommonJS, stdio) qui
donne à Claude un accès en lecture à un WMS EasyWMS (Mecalux) via ses API REST.
Lis [../CLAUDE.md](../CLAUDE.md) et [../DECISIONS.md](../DECISIONS.md) —
en particulier **D8** (invalidation par abonnement), **D10** et **D26**
(caches par application, chargement paresseux) — avant de toucher au code.
**Mission** : rendre les chargements paresseux corrects sous appels
concurrents — lot 6 de [../ROADMAP.md](../ROADMAP.md) : déduplication des
fetchs en vol (L6.1), garde de génération contre les écritures post-invalidation
(L6.2), refus d'encaisser un vide anormal (L6.3). Trois services concernés :
`src/services/workflow-service.js`, `src/services/ad-service.js`,
`src/services/entity-resolver.js`.
**Hors périmètre** :
- La **sérialisation globale des `tools/call`** et le figeage du profil par
appel : c'est l'autre manifestation de la concurrence (bascule de profil
redirigeant les requêtes en vol), point ouvert distinct de la ROADMAP — n'y
touche pas.
- Le reste de la roadmap (L4.3, L4.4, L4.5, Metrics). Aucun nouvel outil, le
compte reste à 23. Aucun changement des schémas d'outils.
- Ne pousse rien (`git push` interdit), ne touche pas au `.env`, n'appelle
jamais `execute_command` (il écrit dans le WMS).
---
## Contexte matériel
- Profil de travail : `LIMAGRAIN` (par défaut), host `10.255.255.2`, tenant
`LIMAGRAI2512`. `EUROTRAFIC` fonctionne (utile pour tester la bascule).
- **Le profil `AD` est cassé et c'est diagnostiqué — ne le réinvestigue pas**
(tenant introuvable côté STS, point ouvert). La baseline se mesure avec
`npm test` (profil par défaut), attendu **4/4, code de sortie 0**.
- Baseline protocolaire : **23 outils**, **6 resources**, rien sur stdout hors
JSON-RPC.
- **Fait établi, ne le redécouvre pas** : le serveur traite les `tools/call`
en **concurrence** — une rafale de requêtes dans une même session s'exécute
en parallèle. C'est précisément ce qui déclenche les bugs de ce lot, et
c'est l'outil de reproduction.
Handshake + comptages :
```bash
printf '%s\n%s\n%s\n%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' '{"jsonrpc":"2.0","method":"notifications/initialized"}' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' '{"jsonrpc":"2.0","id":3,"method":"resources/list"}' | node src/index.js 2>/dev/null | node -e "let b='';process.stdin.on('data',d=>b+=d).on('end',()=>{for(const l of b.split('\n').filter(Boolean)){const m=JSON.parse(l);if(m.id===2)console.log('tools:',m.result.tools.length);if(m.id===3)console.log('resources:',m.result.resources.length);}});"
```
**Rafale concurrente** (l'outil de reproduction de ce lot) : envoyer plusieurs
`tools/call` d'un bloc sur stdin — ils partent en parallèle. Exemple, 6 appels
identiques :
```bash
node -e "
const lines=[JSON.stringify({jsonrpc:'2.0',id:1,method:'initialize',params:{protocolVersion:'2024-11-05',capabilities:{},clientInfo:{name:'t',version:'1'}}}),JSON.stringify({jsonrpc:'2.0',method:'notifications/initialized'})];
for(let i=0;i<6;i++)lines.push(JSON.stringify({jsonrpc:'2.0',id:10+i,method:'tools/call',params:{name:'search_workflows',arguments:{query:'CST_',application:'CustomApp'}}}));
process.stdout.write(lines.join('\n')+'\n');" | node src/index.js 2>stderr.log | node -e "let b='';process.stdin.on('data',d=>b+=d).on('end',()=>{for(const l of b.split('\n').filter(Boolean)){const m=JSON.parse(l);if(m.id>=10){const o=JSON.parse(m.result.content[0].text);console.log('id',m.id,'count',o.count);}}});"; grep -c "fetching from API" stderr.log
```
Pour les vérifications **séquentielles** (une requête après la réponse de la
précédente), écris un petit driver qui n'envoie la ligne suivante qu'à
réception de la réponse — ne réutilise pas la rafale pour ça.
## Contraintes non négociables
1. `console.error()` uniquement (D6) — et les logs de fetch existants
(`fetching from API`, `Cache expired or empty`…) sont l'**observable** des
vérifications de ce lot : garde-les exacts, un par fetch réel.
2. Contrat d'erreur `{ success: false, error, tool }`, `isError: true`.
3. Aucun accès base de données (D1).
4. **D8** : l'invalidation reste pilotée par `onSwitch()` — ce lot la renforce
(L6.2), il ne la remplace pas. N'invalide toujours rien à la main depuis un
autre module.
5. **D26** : chargement paresseux strict — la déduplication ne doit introduire
aucun préchargement.
6. Pas de dépendance externe pour le single-flight : c'est une Map de
promesses, pas une lib.
**Numéro de décision réservé** : **D27** = single-flight + génération de cache
(le motif commun aux trois services). Vérifie que D26 est bien la dernière
décision avant d'écrire.
---
## Phase 0 — Reproduire (time-box : 45 min)
Preuves déjà mesurées (25/08/2026, révisions des lots 2 et 5) à **reproduire
au moins partiellement** — la course est non déterministe, un échec de
reproduction ponctuel n'invalide pas les mesures :
1. **Duplication** (déterministe, reproduis-la telle quelle) : une rafale de
6 appels identiques nécessitant le resolver a produit 6 ×
`[EntityResolver] Cache expired or empty, fetching Metadata...` — soit 6
chargements complets (6 × 6 GET) pour un seul cache. La rafale ci-dessus
doit montrer aujourd'hui plusieurs `fetching from API` pour la même clé.
2. **Résultat faux** (non déterministe) : dans une rafale mixte de 14 appels,
`search_workflows("CST_", application: "CustomApp")` a répondu
`count: 0` (contre 44 en séquentiel) et `get_workflow_details` une réponse
anormale (~982 caractères au lieu de ~23 000). **Cause supposée, à
confirmer ou infirmer pendant l'implémentation de L6.3** : une réponse
transitoirement anormale de l'API sous charge concurrente, encaissée comme
cache vide (voir L6.3). « Non résolu » est une réponse acceptable sur le
déclencheur exact ; le durcissement L6.3 se fait dans tous les cas.
---
## L6.1 — Dédupliquer les fetchs en vol (single-flight)
**Problème.** Aucun des trois services ne mémorise la promesse du chargement
en cours : chaque appelant concurrent qui trouve le cache invalide lance sa
propre chaîne de fetch complète.
**À faire.** Dans `workflow-service` (cache par application **et** liste
allégée d'`Application/GetAll`), `ad-service` (cache par
`(application, type)`), `entity-resolver` (table unique) : mémoriser la
promesse de fetch en vol **par clé de cache** ; tout appelant concurrent sur
la même clé attend cette promesse au lieu de refetcher ; la promesse est
retirée de la Map au règlement (succès **ou** échec — un échec ne doit pas
rester coincé et bloquer les appels suivants : le prochain appel refetche).
**Attention** : deux clés différentes (ex. `EasyWMS` et `CustomApp`) doivent
pouvoir se charger **en parallèle** — le single-flight est par clé, pas global.
**Vérification attendue** (protocole, LIMAGRAIN) :
- Rafale de 6 `search_workflows {"query":"CST_","application":"CustomApp"}`
les 6 réponses portent `count: 44`, et stderr contient **exactement une**
ligne `fetching from API` pour CustomApp.
- Rafale de 6 `query_wms_entities {"entity_type":"Container","limit":1}`
6 succès, **une seule** ligne `[EntityResolver] Cache expired or empty`.
- Rafale mixte EasyWMS + CustomApp → un fetch **par application** (deux au
total), réponses toutes correctes.
## L6.2 — Garde de génération contre les écritures post-invalidation
**Problème.** Un fetch parti avant une invalidation (`clearCache()` /
`invalidateCache()` sur bascule de profil, D8) termine **après** elle et écrit
son résultat : le cache est repeuplé avec les données de l'ancien tenant, avec
un timestamp neuf. Défaut latent, aggravé par le single-flight si la promesse
en vol survit à l'invalidation.
**À faire.** Compteur de génération par service (ou par clé) : incrémenté à
chaque invalidation ; un fetch capture la génération au départ et, au moment
d'écrire, jette son résultat si la génération a changé. L'invalidation vide
aussi la Map des promesses en vol (un appelant en attente sur une promesse de
l'ancienne génération reçoit son résultat mais celui-ci n'est **pas** mis en
cache).
**Vérification attendue** : rafale `[search_workflows (EasyWMS),
switch_wms_profile (EUROTRAFIC)]` envoyée d'un bloc, puis — séquentiellement,
après les deux réponses — `get_application_summary` : **aucun cache** peuplé
(le fetch EasyWMS parti avant la bascule n'a pas repeuplé le cache après
l'invalidation). Rejouer 3 fois (course non déterministe) : zéro occurrence de
cache repeuplé.
## L6.3 — Ne pas encaisser un vide anormal
**Problème.** `fetchAllWorkflows` (`workflow-service.js`, boucle de
pagination) fait `response?.entities || []` : une réponse sans champ
`entities` (forme anormale, quelle qu'en soit la cause) devient un tableau
vide, mis en cache avec un timestamp valide — **cache vide empoisonné pour
tout le TTL**. Cause probable du `count: 0` mesuré. Vérifie si `ad-service`
et la liste d'applications ont le même motif.
**À faire.** Distinguer les deux cas : une réponse **sans** champ `entities`
(undefined/null, forme inattendue) → **lever** une erreur (l'appel échoue,
rien n'est mis en cache, le prochain appel refetche) ; une réponse avec
`entities: []` **réel** → comportement actuel conservé (fin de pagination, et
des applications légitimement vides existent — `SmartUI` a 0 workflow, D26).
**Pente naturelle interdite** : ne transforme pas ça en retry automatique ou
en logique de résilience élaborée — ce lot rend l'anomalie **visible et non
persistante**, c'est tout.
**Vérification attendue** : `search_workflows` sur `SmartUI` (application
légitimement vide) → `count: 0` **sans erreur**, cache posé, et le hint L5.4
présent. Le cas « forme sans `entities` » n'est pas déclenchable à la demande
contre le vrai WMS : couvre-le par un test direct du service en Node (appelle
la fonction avec un `apiService.post` substitué qui renvoie `{}`) et colle la
sortie — l'erreur doit être levée, rien en cache.
---
## Méthode
1. Phase 0 (reproduction, time-boxée).
2. L6.1, puis L6.2, puis L6.3 — dans cet ordre : la garde de génération (L6.2)
s'appuie sur la structure posée en L6.1.
3. Chaque bloc vérifié **en exécution via le protocole** (rafales pour la
concurrence, driver séquentiel pour les états) avant de passer au suivant.
4. Les courses sont non déterministes : rejoue chaque vérification de
concurrence **3 fois** et colle les trois sorties.
5. Aucun schéma d'outil ne change ; si tu touches quand même à `src/index.js`
ou aux schémas, reboucle sur les 23 noms de `tools/list`.
6. Baseline avant/après : handshake (23/6) + `npm test` (4/4, exit 0). Et
vérifie qu'un appel **séquentiel simple** (`search_workflows` stacker → 50,
cache hit au second appel) est strictement inchangé — la déduplication ne
doit rien changer au chemin nominal.
## Livraison
- Un commit par bloc (L6.1, L6.2, L6.3), messages expliquant le pourquoi,
**sorties des vérifications (× 3 pour les rafales) dans le corps du
message**.
- Documentation dans les mêmes commits : **D27** dans DECISIONS.md (motif
single-flight + génération, et le contrat « vide anormal = erreur, vide réel
= cachable ») ; CLAUDE.md — une phrase dans la section Caches ; ROADMAP.md —
retirer le lot 6 (le point ouvert « bascule de profil concurrente » reste,
il n'est **pas** couvert par ce lot).
- Ne pousse pas. `.env` intact. Toute anomalie hors périmètre découverte en
route : dans ROADMAP.md, pas dans le code.
- Compte-rendu final : pour chaque bloc, la vérification attendue rejouée et
ses résultats **mesurés** (colle les sorties, y compris les compteurs de
lignes stderr), plus la baseline finale. Laisse `handoff-lot6.md` en place
pour la révision.