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>
12 KiB
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/callet 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 pushinterdit), ne touche pas au.env, n'appelle jamaisexecute_command(il écrit dans le WMS).
Contexte matériel
- Profil de travail :
LIMAGRAIN(par défaut), host10.255.255.2, tenantLIMAGRAI2512.EUROTRAFICfonctionne (utile pour tester la bascule). - Le profil
ADest cassé et c'est diagnostiqué — ne le réinvestigue pas (tenant introuvable côté STS, point ouvert). La baseline se mesure avecnpm 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/callen 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
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.- Contrat d'erreur
{ success: false, error, tool },isError: true. - Aucun accès base de données (D1).
- 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. - D26 : chargement paresseux strict — la déduplication ne doit introduire aucun préchargement.
- 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 :
- 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 plusieursfetching from APIpour la même clé. - Résultat faux (non déterministe) : dans une rafale mixte de 14 appels,
search_workflows("CST_", application: "CustomApp")a réponducount: 0(contre 44 en séquentiel) etget_workflow_detailsune 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 portentcount: 44, et stderr contient exactement une lignefetching from APIpour 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
- Phase 0 (reproduction, time-boxée).
- 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.
- 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.
- Les courses sont non déterministes : rejoue chaque vérification de concurrence 3 fois et colle les trois sorties.
- Aucun schéma d'outil ne change ; si tu touches quand même à
src/index.jsou aux schémas, reboucle sur les 23 noms detools/list. - Baseline avant/après : handshake (23/6) +
npm test(4/4, exit 0). Et vérifie qu'un appel séquentiel simple (search_workflowsstacker → 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.
.envintact. 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.mden place pour la révision.