L6.1 : dedupliquer les chargements paresseux en vol (single-flight)
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>
This commit is contained in:
@@ -645,3 +645,51 @@ passé), et ajoutent un `hint` quand la recherche revient **vide** :
|
||||
- Il nomme `CustomApp` en clair, sauf quand c'est déjà l'application
|
||||
interrogée : c'est une connaissance statique, déjà portée par les
|
||||
descriptions d'outils, pas une donnée à aller chercher.
|
||||
|
||||
---
|
||||
|
||||
## D27 — Chargements paresseux sous concurrence : single-flight par clé
|
||||
|
||||
**Contexte (mesuré le 25/08/2026, `LIMAGRAI2512`).** Le serveur traite les
|
||||
`tools/call` **en concurrence** : une rafale d'appels dans une même session
|
||||
s'exécute en parallèle. Les trois services à cache chargeaient paresseusement
|
||||
sans se coordonner — le premier appelant qui trouve le cache invalide lance le
|
||||
fetch, et tous ceux qui arrivent pendant ce fetch trouvent le cache **encore**
|
||||
invalide et lancent le leur. Mesures avant correction :
|
||||
|
||||
| Rafale | Résultat |
|
||||
|---|---|
|
||||
| 6 × `search_workflows` (CustomApp) | 6 × `fetching from API` pour une seule clé |
|
||||
| 6 × `query_wms_entities` (Container) | 6 × `[EntityResolver] Cache expired or empty` — soit 30 GET Metadata |
|
||||
| 14 appels mixtes | l'API AD répond **HTTP 500** sur `Workflow/GetByApplication` (EasyWMS, ~4 000 workflows) — les 4 appels EasyWMS échouent, les mêmes passent en séquentiel |
|
||||
|
||||
La dernière ligne est le vrai coût : la duplication ne gaspille pas seulement
|
||||
des appels, elle **surcharge l'API AD au point de la faire échouer**.
|
||||
|
||||
**Décision.** Un motif unique, `src/services/single-flight.js`, partagé par
|
||||
`workflow-service`, `ad-service` et `entity-resolver` — une `Map` de promesses,
|
||||
pas une dépendance externe :
|
||||
|
||||
- **Une clé de single-flight par entrée de cache** : `workflows::<app>` et
|
||||
`applications` pour les workflows, `<app>::<type>` pour l'AD, une clé unique
|
||||
pour le resolver. Deux clés distinctes se chargent toujours **en parallèle** —
|
||||
le single-flight ne sérialise rien au-delà de la clé demandée, et
|
||||
n'introduit aucun préchargement (D26 intact).
|
||||
- **La promesse est retirée au règlement, succès *ou* échec.** Un fetch en
|
||||
erreur ne reste pas coincé dans la Map : l'appel suivant refetche. Les
|
||||
appelants joints reçoivent la même erreur, et rien n'est mis en cache.
|
||||
- **Le log de fetch reste l'observable** (D6) : une ligne `fetching from API`
|
||||
/ `Cache expired or empty` par chargement **réel**. Les appelants joints
|
||||
émettent une ligne distincte (`Fetch already in flight … joining it`) — ne
|
||||
fusionnez pas les deux, c'est ce qui rend la déduplication vérifiable depuis
|
||||
stderr.
|
||||
|
||||
**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
|
||||
total, plus aucun HTTP 500. Le chemin séquentiel nominal est inchangé : 1 fetch
|
||||
puis 1 `Using cached data`, 0 join.
|
||||
|
||||
**Ce que cette décision ne couvre pas.** La bascule de profil concurrente aux
|
||||
appels en vol (un `switch_wms_profile` qui redirige des requêtes déjà parties)
|
||||
reste un point ouvert de la ROADMAP, distinct.
|
||||
|
||||
Reference in New Issue
Block a user