b7151b3bc7
Le serveur traite les tools/call en concurrence. Les trois services a cache chargent paresseusement sans se coordonner : le premier appelant qui trouve le cache invalide lance le fetch, et tous ceux qui arrivent pendant ce fetch le trouvent *encore* invalide et lancent le leur. Une rafale de 6 appels identiques declenchait donc 6 chargements complets pour une seule cle. Ce n'est pas qu'un gaspillage : la duplication surcharge l'API AD au point de la faire echouer. Rafale mixte de 14 appels, avant correction — les 4 appels EasyWMS (~4000 workflows) reviennent en erreur, les memes passent en sequentiel : [Workflow] Error fetching workflows for "EasyWMS": POST https://10.255.255.2/AD/api/Workflow/GetByApplication failed (HTTP 500) fetching from API: 8 | EntityResolver Cache expired or empty: 3 Motif commun extrait dans src/services/single-flight.js — une Map de promesses, pas de dependance externe. Une cle par entree de cache (workflows::<app>, applications, <app>::<type>, metadata) : deux cles distinctes se chargent toujours en parallele, aucun prechargement (D26 intact). La promesse est retiree au reglement, succes *ou* echec, pour qu'un fetch en erreur ne reste pas coince. Le log de fetch reste l'observable (un par chargement reel) ; les appelants joints emettent une ligne distincte "Fetch already in flight ... joining it". --- Verifications (LIMAGRAIN), rafales rejouees 3 fois --- Phase 0, reproduction avant correction : 6 x search_workflows CustomApp -> count 44 x6, 'fetching from API' : 6 6 x query_wms_entities Container -> 6 succes, 'EntityResolver] Cache expired or empty' : 6 Rafale de 6 search_workflows {"query":"CST_","application":"CustomApp"} : ===== RUN 1 ===== ===== RUN 2 ===== ===== RUN 3 ===== id 10 success=true application=CustomApp count=44 (idem RUN 2 et RUN 3, id 11 success=true application=CustomApp count=44 les 6 reponses a 44) id 12 success=true application=CustomApp count=44 id 13 success=true application=CustomApp count=44 id 14 success=true application=CustomApp count=44 id 15 success=true application=CustomApp count=44 -- 'fetching from API' : 1 | 'joining it' : 5 [RUN 1] -- 'fetching from API' : 1 | 'joining it' : 5 [RUN 2] -- 'fetching from API' : 1 | 'joining it' : 5 [RUN 3] Rafale de 6 query_wms_entities {"entity_type":"Container","limit":1} : RUN 1/2/3 : id 10..15 success=true count=1 (6/6) -- 'EntityResolver] Cache expired or empty' : 1 | joins : 5 | GET Metadata/Entities : 5 [identique RUN 1, RUN 2, RUN 3] (avant : 6 chargements, soit 30 GET Metadata) Rafale mixte EasyWMS + CustomApp (3 + 3) — un fetch par application : RUN 1/2/3 : CustomApp count=44 x3, EasyWMS count=50 x3 [Workflow] Cache expired or empty for "CustomApp", fetching from API... [Workflow] Cache expired or empty for "EasyWMS", fetching from API... total fetch=2 joins=4 [identique RUN 1, RUN 2, RUN 3] Plus aucun HTTP 500 : un seul fetch EasyWMS concurrent au lieu de 4. Chemin sequentiel nominal, strictement inchange (driver sequentiel) : [search_workflows] success=true application=EasyWMS count=50 len=14220 [search_workflows] success=true application=EasyWMS count=50 len=14220 --- fetch=1 cached=1 joins=0 Liberation de la Map sur echec (test direct, apiService.post substitue : echoue au 1er appel, reussit ensuite) : [Workflow] Cache expired or empty for "TestApp", fetching from API... [Workflow] Fetch already in flight for "workflows::TestApp", joining it (x2) --- rafale de 3 sur un fetch en echec : appelant 0/1/2: rejected - Failed to fetch workflows ... panne reseau simulee appels reseau reels: 1 (attendu 1 : les 3 partagent le meme fetch) cache pose ? {} (attendu {} : rien en cache sur echec) --- appel suivant (la Map doit avoir ete liberee) : resultat: 1 workflow(s), appels reseau cumules: 2 Baseline : tools/list 23, resources/list 6 ; npm test 4/4 exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
326 lines
11 KiB
JavaScript
326 lines
11 KiB
JavaScript
/**
|
|
* Workflow Service
|
|
* Handles workflow fetching with lazy loading and caching
|
|
* Workflows are only loaded when first requested (not at startup)
|
|
*
|
|
* Un cache par application (D26) : le paramètre `application` des outils
|
|
* sélectionne l'application AD interrogée (défaut : celle du profil actif).
|
|
*/
|
|
|
|
const apiService = require('./api-service').getInstance();
|
|
const profileManager = require('../config/profile-manager');
|
|
const { createSingleFlight } = require('./single-flight');
|
|
|
|
// Cache state — un cache de workflows par application (D26)
|
|
let workflowCaches = {}; // application -> workflows[]
|
|
let cacheTimestamps = {}; // application -> timestamp
|
|
let applicationsCache = null; // liste allégée de POST /Application/GetAll
|
|
let applicationsTimestamp = null;
|
|
const CACHE_TTL = parseInt(process.env.WORKFLOW_CACHE_TTL) || 3600000; // 1 hour in milliseconds
|
|
|
|
// Déduplication des chargements concurrents, par clé de cache (D27). Deux
|
|
// clés distinctes ici : une par application, plus la liste d'applications.
|
|
const singleFlight = createSingleFlight('Workflow');
|
|
|
|
// Clear cache when profile changes — workflows are per-tenant, so the previous
|
|
// profile's cache is meaningless after a switch.
|
|
profileManager.onSwitch(() => clearCache());
|
|
|
|
/**
|
|
* Application effective : celle demandée, sinon celle du profil actif.
|
|
*/
|
|
function resolveApplication(application) {
|
|
return (application && application.trim()) || profileManager.getCurrent().application;
|
|
}
|
|
|
|
/**
|
|
* Check if cache is still valid for an application
|
|
*/
|
|
function isCacheValid(application) {
|
|
if (!workflowCaches[application] || !cacheTimestamps[application]) {
|
|
return false;
|
|
}
|
|
|
|
const age = Date.now() - cacheTimestamps[application];
|
|
return age < CACHE_TTL;
|
|
}
|
|
|
|
/**
|
|
* Fetch all workflows of an application from API with pagination.
|
|
* Uses high page size (5000) to minimize API calls.
|
|
* Lazy : seule l'application effectivement demandée est chargée (D26) — ne
|
|
* jamais précharger les 9 applications.
|
|
* @param {string} [application] - Application AD (défaut : profil actif)
|
|
*/
|
|
async function fetchAllWorkflows(application) {
|
|
const app = resolveApplication(application);
|
|
|
|
// Check cache validity
|
|
if (isCacheValid(app)) {
|
|
console.error(`[Workflow] Using cached data for "${app}"`);
|
|
return workflowCaches[app];
|
|
}
|
|
|
|
// Un seul chargement par application, même sous rafale concurrente (D27).
|
|
return singleFlight.run(`workflows::${app}`, () => loadWorkflows(app));
|
|
}
|
|
|
|
/**
|
|
* Chargement réel des workflows d'une application (pagination complète).
|
|
* Appelé au plus une fois par application tant qu'il est en vol (D27).
|
|
*/
|
|
async function loadWorkflows(app) {
|
|
console.error(`[Workflow] Cache expired or empty for "${app}", fetching from API...`);
|
|
|
|
try {
|
|
let allWorkflows = [];
|
|
let offset = 0;
|
|
const pageSize = parseInt(process.env.WORKFLOW_PAGE_SIZE) || 5000;
|
|
const tenant = profileManager.getCurrent().tenant;
|
|
|
|
while (true) {
|
|
const body = [app, tenant, pageSize, offset];
|
|
|
|
console.error(`[Workflow] Fetching page: application=${app}, offset=${offset}, pageSize=${pageSize}`);
|
|
|
|
// Use AD API (useAdApi=true)
|
|
const response = await apiService.post('/Workflow/GetByApplication', body, true);
|
|
|
|
// Extract entities array from response
|
|
const workflows = response?.entities || [];
|
|
|
|
// Check if response is valid
|
|
if (!workflows || workflows.length === 0) {
|
|
console.error('[Workflow] No more workflows to fetch');
|
|
break;
|
|
}
|
|
|
|
allWorkflows = allWorkflows.concat(workflows);
|
|
console.error(`[Workflow] Fetched ${workflows.length} workflows (total: ${allWorkflows.length})`);
|
|
|
|
// If we got less than page size, we've reached the last page
|
|
if (workflows.length < pageSize) {
|
|
console.error('[Workflow] Last page reached');
|
|
break;
|
|
}
|
|
|
|
offset += pageSize;
|
|
}
|
|
|
|
// Update cache
|
|
workflowCaches[app] = allWorkflows;
|
|
cacheTimestamps[app] = Date.now();
|
|
|
|
console.error(`[Workflow] Successfully cached ${allWorkflows.length} workflows for "${app}"`);
|
|
return allWorkflows;
|
|
} catch (error) {
|
|
console.error(`[Workflow] Error fetching workflows for "${app}":`, error.message);
|
|
throw new Error(`Failed to fetch workflows for application "${app}": ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Liste les applications déclarées (POST /Application/GetAll, payload null).
|
|
* La réponse est une enveloppe { entities: [...] } (D4) dont chaque élément
|
|
* porte un blob `data` volumineux — on ne conserve que les champs légers.
|
|
* Cache TTL commun, vidé au switch de profil.
|
|
* @returns {Promise<Array<{name: string, id: string, version: number}>>}
|
|
*/
|
|
async function fetchApplications() {
|
|
const cached = getCachedApplications();
|
|
if (cached) return cached;
|
|
|
|
// Même déduplication que les workflows, sur sa propre clé (D27).
|
|
return singleFlight.run('applications', loadApplications);
|
|
}
|
|
|
|
/**
|
|
* Chargement réel de la liste d'applications (D27).
|
|
*/
|
|
async function loadApplications() {
|
|
console.error('[Workflow] Fetching application list (Application/GetAll)...');
|
|
const response = await apiService.post('/Application/GetAll', null, true);
|
|
const entities = response?.entities || [];
|
|
|
|
applicationsCache = entities.map(a => ({
|
|
name: a.name || a.Name,
|
|
id: a.id || a.Id,
|
|
version: a.version ?? a.Version,
|
|
})).filter(a => a.name);
|
|
applicationsTimestamp = Date.now();
|
|
|
|
console.error(`[Workflow] Cached ${applicationsCache.length} application(s)`);
|
|
return applicationsCache;
|
|
}
|
|
|
|
/**
|
|
* Liste des applications déjà en cache, ou null si le cache est vide/expiré.
|
|
* Ne déclenche AUCUN appel réseau — c'est ce qui permet d'enrichir une réponse
|
|
* de recherche sans jamais précharger une application non demandée (D26).
|
|
* @returns {Array<{name: string, id: string, version: number}>|null}
|
|
*/
|
|
function getCachedApplications() {
|
|
if (applicationsCache && applicationsTimestamp &&
|
|
Date.now() - applicationsTimestamp < CACHE_TTL) {
|
|
return applicationsCache;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Hint de découvrabilité (L5.4). Une recherche n'interroge qu'UNE application
|
|
* sur les neuf déclarées, et rien dans la réponse ne le disait : une session
|
|
* cherchant des workflows `CST_*` sans `application: "CustomApp"` a conclu à
|
|
* tort qu'il n'y en avait aucun (25/08/2026).
|
|
*
|
|
* Les autres applications sont nommées depuis la liste allégée **déjà en
|
|
* cache** ; sans elle, le hint reste générique et renvoie vers
|
|
* `list_workflow_categories` — jamais de fetch pour construire un hint.
|
|
*
|
|
* @param {string} application - application effectivement interrogée
|
|
* @param {string} sujet - ce qui a été cherché ('workflow', 'élément Command'…)
|
|
*/
|
|
function buildOtherApplicationsHint(application, sujet) {
|
|
const cached = getCachedApplications();
|
|
const others = (cached || []).map(a => a.name).filter(n => n !== application);
|
|
|
|
const liste = others.length
|
|
? `Autres applications déclarées sur ce tenant : ${others.join(', ')}.`
|
|
: `Appelez list_workflow_categories pour lister les autres applications déclarées.`;
|
|
|
|
const custom = application.toLowerCase() === 'customapp'
|
|
? ''
|
|
: ` Le spécifique client (préfixe CST_) vit dans "CustomApp" : relancez avec application: "CustomApp".`;
|
|
|
|
return `Aucun ${sujet} trouvé dans l'application "${application}" — c'est la SEULE interrogée, ` +
|
|
`les autres ne le sont jamais implicitement.${custom} ${liste}`;
|
|
}
|
|
|
|
/**
|
|
* Search workflows by query string.
|
|
* Real AD keys (lowercase, cf. D5): id, name, version, applicationName,
|
|
* commonInfo — no description/code/category field exists.
|
|
* @param {string} query - Search query (matches workflow name)
|
|
* @param {string|null} category - Optional applicationName filter (the only
|
|
* grouping the AD API provides)
|
|
* @param {number} limit - Maximum results to return
|
|
* @param {string} [application] - Application AD interrogée (défaut : profil)
|
|
*/
|
|
async function searchWorkflows(query, category = null, limit = 50, application) {
|
|
const workflows = await fetchAllWorkflows(application);
|
|
|
|
let results = workflows;
|
|
|
|
// Filter by query if provided
|
|
if (query) {
|
|
const lowerQuery = query.toLowerCase();
|
|
results = results.filter(w => {
|
|
const name = (w.name || w.Name || '').toLowerCase();
|
|
return name.includes(lowerQuery);
|
|
});
|
|
}
|
|
|
|
// Filter by applicationName if provided
|
|
if (category) {
|
|
const lowerCategory = category.toLowerCase();
|
|
results = results.filter(w => {
|
|
const applicationName = (w.applicationName || w.ApplicationName || '').toLowerCase();
|
|
return applicationName.includes(lowerCategory);
|
|
});
|
|
}
|
|
|
|
// Limit results
|
|
return results.slice(0, limit);
|
|
}
|
|
|
|
/**
|
|
* Get workflow details by ID
|
|
* @param {string|number} workflowId - Workflow ID
|
|
* @param {string} [application] - Application AD interrogée (défaut : profil)
|
|
*/
|
|
async function getWorkflowDetails(workflowId, application) {
|
|
// Garde d'entrée : sans elle, un workflow_id absent matchait le premier
|
|
// workflow du cache (undefined === undefined sur les clés mortes ci-dessous).
|
|
if (workflowId == null || workflowId === '') {
|
|
throw new Error('workflow_id est requis (id ou nom exact du workflow). Utilisez search_workflows pour le trouver.');
|
|
}
|
|
|
|
const app = resolveApplication(application);
|
|
const workflows = await fetchAllWorkflows(app);
|
|
|
|
// Clés réelles de l'API AD (minuscules, D5) : id, name. Les variantes
|
|
// Id/Code/Name n'existent pas sur ces objets — les comparer faisait matcher
|
|
// undefined === undefined dès que workflow_id manquait.
|
|
const workflow = workflows.find(w =>
|
|
w.id === workflowId ||
|
|
w.name === workflowId
|
|
);
|
|
|
|
if (!workflow) {
|
|
throw new Error(
|
|
`Workflow not found: ${workflowId} (application "${app}"). ` +
|
|
`Utilisez search_workflows pour trouver l'id ou le nom exact — ` +
|
|
`pensez au paramètre application (ex: "CustomApp" pour le spécifique client).`
|
|
);
|
|
}
|
|
|
|
return workflow;
|
|
}
|
|
|
|
/**
|
|
* Get workflow statistics for one application
|
|
* @param {string} [application] - Application AD interrogée (défaut : profil)
|
|
*/
|
|
async function getWorkflowStats(application) {
|
|
const app = resolveApplication(application);
|
|
const workflows = await fetchAllWorkflows(app);
|
|
|
|
return {
|
|
application: app,
|
|
total: workflows.length,
|
|
cacheAge: cacheTimestamps[app] ? Math.floor((Date.now() - cacheTimestamps[app]) / 1000) : null
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clear workflow caches (force refresh on next request) — toutes applications.
|
|
*/
|
|
function clearCache() {
|
|
workflowCaches = {};
|
|
cacheTimestamps = {};
|
|
applicationsCache = null;
|
|
applicationsTimestamp = null;
|
|
console.error('[Workflow] Cache cleared');
|
|
}
|
|
|
|
/**
|
|
* Get cache status, per application (D26)
|
|
* @returns {Object} application -> { cached, count, timestamp, age, valid }
|
|
*/
|
|
function getCacheStatus() {
|
|
const status = {};
|
|
Object.keys(workflowCaches).forEach(app => {
|
|
status[app] = {
|
|
cached: true,
|
|
count: workflowCaches[app].length,
|
|
timestamp: cacheTimestamps[app],
|
|
age: cacheTimestamps[app] ? Math.floor((Date.now() - cacheTimestamps[app]) / 1000) : null,
|
|
valid: isCacheValid(app)
|
|
};
|
|
});
|
|
return status;
|
|
}
|
|
|
|
module.exports = {
|
|
fetchAllWorkflows,
|
|
fetchApplications,
|
|
getCachedApplications,
|
|
buildOtherApplicationsHint,
|
|
resolveApplication,
|
|
searchWorkflows,
|
|
getWorkflowDetails,
|
|
getWorkflowStats,
|
|
clearCache,
|
|
getCacheStatus
|
|
};
|