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:
+43
-16
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user