From 242b0c0f1c6c94e82cf09a888c202fa5247cc729 Mon Sep 17 00:00:00 2001 From: Arthur Ria Date: Tue, 25 Aug 2026 15:54:54 +0200 Subject: [PATCH] L6.3 : une reponse hors enveloppe leve, au lieu de se faire passer pour vide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les services lisaient `response?.entities || []` sur les reponses de l'API AD (enveloppe { entities: [...] }, D4). Toute reponse d'une AUTRE forme — corps vide, objet d'erreur, champ absent — devenait donc un tableau vide, indistinguable d'une page finale legitime, et etait mise en cache avec un timestamp valide : un cache vide empoisonne pour tout le TTL, sans le moindre message. C'est la cause probable du `count: 0` mesure sous rafale, et le mode d'echec le plus couteux du lot, parce qu'il se lit comme une reponse. Le contrat est porte par src/services/ad-envelope.js pour les trois sites (Workflow/GetByApplication, Application/GetAll, /GetByApplication) : `{ entities: [...] }`, `[]` reel compris, est rendu tel quel ; toute autre forme leve. entity-resolver etait deja conforme — il leve deja si /configuration/applications ou le Metadata ne rendent aucune entite. Volontairement sans retry ni logique de resilience : le but est de rendre l'anomalie visible et non persistante. La rattraper la rendrait invisible, c'est-a-dire exactement le defaut corrige. --- Verifications (LIMAGRAIN) --- Vide LEGITIME — search_workflows sur SmartUI (0 workflow, D26) : search_workflows(SmartUI) : success=true application=SmartUI count=0 isError=false error : (aucune) hint : Aucun workflow trouve dans l'application "SmartUI" — c'est la SEULE interrogee, les autres ne le sont jamais implicitement. Le specifique client (prefixe CST_) vit dans "CustomApp" : [...] cache pose ? workflowCachesByApplication = {"SmartUI":{"cached":true,"count":0,"timestamp":1787665868988, "age":0,"valid":true}} stderr : No more workflows to fetch / Successfully cached 0 workflows Forme SANS `entities` — non declenchable a la demande contre le vrai WMS, couverte par un test direct (apiService.post substitue, renvoie {}) : --- workflow-service fetchAllWorkflows("EasyWMS") avec une reponse {} --- erreur levee : Failed to fetch workflows for application "EasyWMS": Reponse inattendue de l'API AD sur Workflow/GetByApplication (application "EasyWMS", offset 0) : un objet vide, au lieu de l'enveloppe attendue { entities: [...] }. Rien n'a ete mis en cache — relancez l'appel. Si l'erreur persiste, l'API AD est en defaut [...] cache : {} (attendu {}) --- workflow-service fetchApplications() avec une reponse {} --- erreur levee : Reponse inattendue de l'API AD sur Application/GetAll : [...] cache : {} (attendu {}) --- ad-service getElements("Command") avec une reponse {} --- erreur levee : Failed to fetch Command for application "EasyWMS": [...] cache : {} (attendu {}) --- puis une reponse normale : le refetch repart (rien de coince) --- 1 workflow(s), cache : {"EasyWMS":{"cached":true,"count":1,...}} Cas nominaux du helper (unitaire) : { entities: [] } et { entities: [1,2] } passent ; {}, null, undefined, [], { error }, "texte" levent tous. --- Non-regression, rafales rejouees 3 fois --- L6.1 rafale workflow RUN 1/2/3 : 6/6 a count=44 | fetch=1 joins=5 L6.1 rafale resolver RUN 1/2/3 : 6/6 succes | chargements=1 joins=5 GET=5 L6.1 rafale mixte RUN 1/2/3 : CustomApp@44=3 EasyWMS@50=3 | fetch=2 joins=4 L6.2 bascule RUN 1/2/3 : caches peuples : 0 (attendu 0) sequentiel nominal count=50 puis count=50 | fetch=1 cached=1 joins=0 Baseline finale : tools/list 23, resources/list 6 ; npm test 4/4, exit 0. ROADMAP : lot 6 retire. Le point ouvert « bascule de profil concurrente aux appels en vol » reste — D27 borne les chargements paresseux, pas le routage d'une requete deja partie. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 7 ++++ DECISIONS.md | 26 ++++++++++++++ ROADMAP.md | 28 ++------------- src/services/ad-envelope.js | 59 ++++++++++++++++++++++++++++++++ src/services/ad-service.js | 13 ++++--- src/services/workflow-service.js | 17 ++++++--- 6 files changed, 116 insertions(+), 34 deletions(-) create mode 100644 src/services/ad-envelope.js diff --git a/CLAUDE.md b/CLAUDE.md index 70a3ffd..d7b4905 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,8 @@ src/ │ ├── workflow-service.js Workflows, lazy loading + cache │ ├── ad-service.js Application Dictionary, 20 types, cache par type │ ├── wms-query-service.js Construction d'expressions LINQ +│ ├── single-flight.js Déduplication des chargements + génération (D27) +│ ├── ad-envelope.js Enveloppe { entities } des API AD : vide anormal = erreur (D27) │ ├── log-service.js Lecture et recherche dans les fichiers de logs │ └── response-limit.js Plafond de taille commun aux outils de requête (D24) └── tools/ 23 outils MCP @@ -206,6 +208,11 @@ fetch parti avant une invalidation ne repeuple plus le cache après elle : la publication passe par un `commit` gardé par un compteur de génération. **Ne remettez jamais d'écriture de cache dans une fonction de chargement.** +Une réponse d'API AD **hors enveloppe** `{ entities: [...] }` lève au lieu de +passer pour un tableau vide : sinon un cache vide s'installe pour tout le TTL +(D27). Un `entities: []` **réel** reste cachable — des applications sont +légitimement vides. + `get_application_summary` expose l'état des caches sans redémarrage. --- diff --git a/DECISIONS.md b/DECISIONS.md index f21728c..cd10b86 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -707,6 +707,32 @@ d'un bloc, puis `get_application_summary` en séquentiel : **3/3 avant**, le cache EasyWMS de LIMAGRAIN (3 944 workflows) survit à la bascule avec un timestamp neuf ; **3/3 après**, aucun cache peuplé. +**Vide anormal ≠ vide réel.** Les services lisaient `response?.entities || []` +sur les réponses de l'API AD (enveloppe `{ entities: [...] }`, D4). Toute +réponse d'une **autre forme** devenait donc un tableau vide, indistinguable +d'une page finale légitime — et mise en cache avec un timestamp valide : un +cache vide empoisonné pour tout le TTL, sans message. C'est la cause probable +du `count: 0` mesuré sous rafale, et le mode d'échec le plus coûteux du lot : +il se lit comme une réponse. + +`src/services/ad-envelope.js` porte le contrat pour les trois sites +(`Workflow/GetByApplication`, `Application/GetAll`, `/GetByApplication`) : + +| Réponse | Traitement | +|---|---| +| `{ entities: [...] }`, `[]` réel compris | rendue telle quelle — une application peut être légitimement vide (`SmartUI` : 0 workflow ; 3 types AD valides mais vides, D17) | +| toute autre forme | **lève** — l'appel échoue, rien n'est mis en cache, l'appel suivant refetche | + +**Pas de retry, pas de résilience.** L'anomalie doit être **visible et non +persistante** ; la rattraper la rendrait invisible, ce qui est exactement le +défaut corrigé. `entity-resolver` était déjà conforme : il lève déjà si +`/configuration/applications` ou le Metadata ne rendent aucune entité. + +Mesures : `search_workflows` sur `SmartUI` → `count: 0`, `success: true`, +cache posé (`count: 0`, `valid: true`) et hint L5.4 présent. Les trois sites +face à une réponse `{}` → erreur levée, `{}` en cache, et le fetch suivant +repart normalement. + **Mesures après.** Rafale de 6 (CustomApp) → 1 fetch + 5 joins, les 6 réponses à `count: 44`. Rafale de 6 (resolver) → 1 chargement, 5 GET Metadata au lieu de 30. Rafale mixte EasyWMS + CustomApp → **un fetch par application**, deux au diff --git a/ROADMAP.md b/ROADMAP.md index a562482..9931535 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -82,30 +82,6 @@ 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.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é | Proposition | Raison | @@ -135,7 +111,9 @@ forme sans `entities` doit lever ; un `entities: []` réel reste cachable singleton d'état global (D8). À 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 début de chaque appel). La manifestation « caches » de la même concurrence - est traitée par le **lot 6** ci-dessus. + est **traitée** (D27 : single-flight par clé, garde de génération) ; celle-ci + ne l'est pas — D27 borne les chargements paresseux, pas le routage d'une + requête déjà partie. - **`select_expression`** : les projections via le paramètre `Select` provoquent 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 diff --git a/src/services/ad-envelope.js b/src/services/ad-envelope.js new file mode 100644 index 0000000..e9cdbea --- /dev/null +++ b/src/services/ad-envelope.js @@ -0,0 +1,59 @@ +/** + * Enveloppe des API AD — un vide anormal n'est pas un vide (D27) + * + * Les API AD renvoient `{ entities: [...] }` (D4). Les services lisaient + * `response?.entities || []` : toute réponse d'une **autre forme** (pas de + * champ `entities`, corps vide, objet d'erreur) devenait un tableau vide, + * indistinguable d'une page finale légitime — donc mise en cache avec un + * timestamp valide. Un cache vide empoisonné pour tout le TTL, sans le + * moindre message. + * + * Deux cas, deux traitements : + * + * | Réponse | Traitement | + * |---|---| + * | `{ entities: [...] }`, y compris `[]` réel | rendue telle quelle — une application peut être légitimement vide (`SmartUI` : 0 workflow, D26) | + * | tout le reste | **lève** — l'appel échoue, rien n'est mis en cache, l'appel suivant refetche | + * + * Volontairement sans retry ni résilience : le but est de rendre l'anomalie + * **visible et non persistante**, pas de la rattraper. + */ + +/** + * Décrit la forme reçue, pour un message d'erreur exploitable (convention 4). + */ +function describeShape(response) { + if (response === null) return 'null'; + if (response === undefined) return 'undefined'; + if (Array.isArray(response)) return `un tableau nu de ${response.length} élément(s)`; + if (typeof response !== 'object') return `un ${typeof response}`; + const keys = Object.keys(response); + if (keys.length === 0) return 'un objet vide'; + return `un objet sans champ "entities" (champs reçus : ${keys.slice(0, 10).join(', ')})`; +} + +/** + * Extrait le tableau `entities` d'une réponse d'API AD, ou lève. + * + * @param {any} response - la réponse brute de `apiService.post(..., true)` + * @param {string} context - l'appel concerné, pour le message d'erreur + * (ex. `Workflow/GetByApplication (application "EasyWMS", offset 0)`) + * @returns {Array} le tableau `entities`, éventuellement vide + * @throws {Error} si la réponse n'a pas la forme `{ entities: [...] }` + */ +function requireEntities(response, context) { + const entities = response ? response.entities : undefined; + + if (!Array.isArray(entities)) { + throw new Error( + `Réponse inattendue de l'API AD sur ${context} : ${describeShape(response)}, ` + + `au lieu de l'enveloppe attendue { entities: [...] }. ` + + `Rien n'a été mis en cache — relancez l'appel. ` + + `Si l'erreur persiste, l'API AD est en défaut (elle échoue notamment sous appels concurrents nombreux).` + ); + } + + return entities; +} + +module.exports = { requireEntities }; diff --git a/src/services/ad-service.js b/src/services/ad-service.js index 3b80a4b..8872828 100644 --- a/src/services/ad-service.js +++ b/src/services/ad-service.js @@ -7,6 +7,7 @@ const apiService = require('./api-service').getInstance(); const profileManager = require('../config/profile-manager'); const { createSingleFlight } = require('./single-flight'); +const { requireEntities } = require('./ad-envelope'); // Cache state - one cache per (application, element type) (D26) const cache = {}; @@ -135,11 +136,15 @@ async function loadElements(app, elementType, key) { // Use AD API (useAdApi=true) const response = await apiService.post(`/${elementType}/GetByApplication`, body, true); - // Extract entities array from response - const elements = response?.entities || []; + // Une réponse hors enveloppe { entities: [...] } lève au lieu de se + // faire passer pour une page vide (D27). + const elements = requireEntities( + response, + `${elementType}/GetByApplication (application "${app}", offset ${offset})` + ); - // Check if response is valid - if (!elements || elements.length === 0) { + // Vide réel : fin de pagination (3 types sont valides mais vides, D17). + if (elements.length === 0) { console.error(`[AD] No more ${elementType} to fetch`); break; } diff --git a/src/services/workflow-service.js b/src/services/workflow-service.js index e9cae83..ced9bea 100644 --- a/src/services/workflow-service.js +++ b/src/services/workflow-service.js @@ -10,6 +10,7 @@ const apiService = require('./api-service').getInstance(); const profileManager = require('../config/profile-manager'); const { createSingleFlight } = require('./single-flight'); +const { requireEntities } = require('./ad-envelope'); // Cache state — un cache de workflows par application (D26) let workflowCaches = {}; // application -> workflows[] @@ -98,11 +99,17 @@ async function loadWorkflows(app) { // Use AD API (useAdApi=true) const response = await apiService.post('/Workflow/GetByApplication', body, true); - // Extract entities array from response - const workflows = response?.entities || []; + // Une réponse hors enveloppe { entities: [...] } lève au lieu de se + // faire passer pour une page vide (D27) : un cache vide empoisonné + // durerait tout le TTL. + const workflows = requireEntities( + response, + `Workflow/GetByApplication (application "${app}", offset ${offset})` + ); - // Check if response is valid - if (!workflows || workflows.length === 0) { + // Vide réel : fin de pagination (une application peut n'avoir aucun + // workflow — SmartUI, D26). + if (workflows.length === 0) { console.error('[Workflow] No more workflows to fetch'); break; } @@ -151,7 +158,7 @@ async function fetchApplications() { async function loadApplications() { console.error('[Workflow] Fetching application list (Application/GetAll)...'); const response = await apiService.post('/Application/GetAll', null, true); - const entities = response?.entities || []; + const entities = requireEntities(response, 'Application/GetAll'); return entities.map(a => ({ name: a.name || a.Name,