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

12 KiB
Raw Blame History

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 et ../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 : 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 :

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 :

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.