L6.3 : une reponse hors enveloppe leve, au lieu de se faire passer pour vide
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, <Type>/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 <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,8 @@ src/
|
|||||||
│ ├── workflow-service.js Workflows, lazy loading + cache
|
│ ├── workflow-service.js Workflows, lazy loading + cache
|
||||||
│ ├── ad-service.js Application Dictionary, 20 types, cache par type
|
│ ├── ad-service.js Application Dictionary, 20 types, cache par type
|
||||||
│ ├── wms-query-service.js Construction d'expressions LINQ
|
│ ├── 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
|
│ ├── log-service.js Lecture et recherche dans les fichiers de logs
|
||||||
│ └── response-limit.js Plafond de taille commun aux outils de requête (D24)
|
│ └── response-limit.js Plafond de taille commun aux outils de requête (D24)
|
||||||
└── tools/ 23 outils MCP
|
└── 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
|
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.**
|
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.
|
`get_application_summary` expose l'état des caches sans redémarrage.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
|
cache EasyWMS de LIMAGRAIN (3 944 workflows) survit à la bascule avec un
|
||||||
timestamp neuf ; **3/3 après**, aucun cache peuplé.
|
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`, `<Type>/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
|
**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
|
à `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
|
30. Rafale mixte EasyWMS + CustomApp → **un fetch par application**, deux au
|
||||||
|
|||||||
+3
-25
@@ -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é
|
## Écarté
|
||||||
|
|
||||||
| Proposition | Raison |
|
| 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
|
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
|
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
|
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
|
- **`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,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 };
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
const apiService = require('./api-service').getInstance();
|
const apiService = require('./api-service').getInstance();
|
||||||
const profileManager = require('../config/profile-manager');
|
const profileManager = require('../config/profile-manager');
|
||||||
const { createSingleFlight } = require('./single-flight');
|
const { createSingleFlight } = require('./single-flight');
|
||||||
|
const { requireEntities } = require('./ad-envelope');
|
||||||
|
|
||||||
// Cache state - one cache per (application, element type) (D26)
|
// Cache state - one cache per (application, element type) (D26)
|
||||||
const cache = {};
|
const cache = {};
|
||||||
@@ -135,11 +136,15 @@ async function loadElements(app, elementType, key) {
|
|||||||
// Use AD API (useAdApi=true)
|
// Use AD API (useAdApi=true)
|
||||||
const response = await apiService.post(`/${elementType}/GetByApplication`, body, true);
|
const response = await apiService.post(`/${elementType}/GetByApplication`, body, true);
|
||||||
|
|
||||||
// Extract entities array from response
|
// Une réponse hors enveloppe { entities: [...] } lève au lieu de se
|
||||||
const elements = response?.entities || [];
|
// faire passer pour une page vide (D27).
|
||||||
|
const elements = requireEntities(
|
||||||
|
response,
|
||||||
|
`${elementType}/GetByApplication (application "${app}", offset ${offset})`
|
||||||
|
);
|
||||||
|
|
||||||
// Check if response is valid
|
// Vide réel : fin de pagination (3 types sont valides mais vides, D17).
|
||||||
if (!elements || elements.length === 0) {
|
if (elements.length === 0) {
|
||||||
console.error(`[AD] No more ${elementType} to fetch`);
|
console.error(`[AD] No more ${elementType} to fetch`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
const apiService = require('./api-service').getInstance();
|
const apiService = require('./api-service').getInstance();
|
||||||
const profileManager = require('../config/profile-manager');
|
const profileManager = require('../config/profile-manager');
|
||||||
const { createSingleFlight } = require('./single-flight');
|
const { createSingleFlight } = require('./single-flight');
|
||||||
|
const { requireEntities } = require('./ad-envelope');
|
||||||
|
|
||||||
// Cache state — un cache de workflows par application (D26)
|
// Cache state — un cache de workflows par application (D26)
|
||||||
let workflowCaches = {}; // application -> workflows[]
|
let workflowCaches = {}; // application -> workflows[]
|
||||||
@@ -98,11 +99,17 @@ async function loadWorkflows(app) {
|
|||||||
// Use AD API (useAdApi=true)
|
// Use AD API (useAdApi=true)
|
||||||
const response = await apiService.post('/Workflow/GetByApplication', body, true);
|
const response = await apiService.post('/Workflow/GetByApplication', body, true);
|
||||||
|
|
||||||
// Extract entities array from response
|
// Une réponse hors enveloppe { entities: [...] } lève au lieu de se
|
||||||
const workflows = response?.entities || [];
|
// 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
|
// Vide réel : fin de pagination (une application peut n'avoir aucun
|
||||||
if (!workflows || workflows.length === 0) {
|
// workflow — SmartUI, D26).
|
||||||
|
if (workflows.length === 0) {
|
||||||
console.error('[Workflow] No more workflows to fetch');
|
console.error('[Workflow] No more workflows to fetch');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -151,7 +158,7 @@ async function fetchApplications() {
|
|||||||
async function loadApplications() {
|
async function loadApplications() {
|
||||||
console.error('[Workflow] Fetching application list (Application/GetAll)...');
|
console.error('[Workflow] Fetching application list (Application/GetAll)...');
|
||||||
const response = await apiService.post('/Application/GetAll', null, true);
|
const response = await apiService.post('/Application/GetAll', null, true);
|
||||||
const entities = response?.entities || [];
|
const entities = requireEntities(response, 'Application/GetAll');
|
||||||
|
|
||||||
return entities.map(a => ({
|
return entities.map(a => ({
|
||||||
name: a.name || a.Name,
|
name: a.name || a.Name,
|
||||||
|
|||||||
Reference in New Issue
Block a user