Files
mcp-wms-api/docs/handoff-lot6.md
T
Arthur Ria 8a631f5ea4 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>
2026-08-25 15:34:01 +02:00

214 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.