diff --git a/docs/qlik-performance.md b/docs/qlik-performance.md new file mode 100644 index 0000000..9a8c337 --- /dev/null +++ b/docs/qlik-performance.md @@ -0,0 +1,106 @@ +# Qlik — Performance & tuning de la sync réseau + +Ce document rassemble les variables d'environnement et leviers qui agissent +sur la **vitesse** et la **robustesse** de l'extraction Qlik réseau +(`POST /api/qlik/sync`, routeur `src/app/api/qlik/sync/route.ts`, +extracteur `src/lib/qlik-playwright.ts`, helper de fenêtre +`src/lib/qlik-date-range.ts`). + +Toutes ces variables sont **optionnelles** : valeur par défaut sûre. + +> ⚠️ Aucune concurrence / parallélisme Qlik n'a été ajouté — chaque sync +> reste **mono-flux** (un seul websocket Engine, une seule fenêtre +> temporelle). Les optimisations ci-dessous n'augmentent pas la charge +> sur le moteur Qlik ; elles **réduisent** la quantité de travail qu'on +> lui demande. + +--- + +## Variables d'environnement + +| Nom | Type | Défaut | Bornes | Effet | +| --------------------------- | ---- | ------ | ------------------- | -------------------------------------------------------------------------------------- | +| `QLIK_SYNC_MONTHS_BACK` | int | `12` | `1`..`12` | Profondeur de la fenêtre temporelle (mois glissants, mois courant exclu). Plus petit = moins de mois scannés = extraction plus rapide. | +| `QLIK_SETTLE_MS` | int | `150` | `0`..`2000` | Latence (ms) après chaque mutation de sélection (Date / Article Code) avant l'appel RPC suivant. Protège l'Engine contre les collisions qui provoquent `code:15 Request aborted`. | +| `QLIK_CODE15_MAX_ATTEMPTS` | int | `4` | `1`..`8` | Nombre max de retries sur Engine `code:15` par appel RPC, avec backoff `QLIK_SETTLE_MS * attempt`. | +| `QLIK_ARTICLE_BATCH_SIZE` | int | `150` | `1`..`500` | Taille du premier lot d'`Article Code` sélectionné par appel `SelectValues`. | +| `QLIK_ARTICLE_BATCH_FALLBACKS` | CSV | `75,50` | chaque valeur `1`..`500` | Tailles de repli quand un lot déclenche un `code:15`. Valeurs ordonnées croissant appliquées successivement. | + +> **Format CSV pour `QLIK_ARTICLE_BATCH_FALLBACKS`** : valeurs séparées par +> des virgules, ex. `QLIK_ARTICLE_BATCH_FALLBACKS=100,75,50,25`. Les doublons +> et valeurs hors bornes sont dédupliqués / clampés automatiquement, puis appliqués dans l'ordre fourni. + +--- + +## Optimisations déjà en place (pattern durable) + +Le pipeline reste **mono-connexion** pour ne jamais surcharger le moteur Qlik : + +1. **Fenêtre temporelle bornée** : on ne demande jamais à Qlik de scanner + l'historique complet ; uniquement les `QLIK_SYNC_MONTHS_BACK` derniers + mois. +2. **Découpe mensuelle** : `getMonthRanges()` produit 1 sous-fenêtre par mois + calendaire. Chaque cube reste ainsi borné à ~28–31 jours × N codes. +3. **Hypercube session object JETABLE** : `fetchCubeForSelection()` crée un + `CreateSessionObject` neuf par lot, lit ses pages, puis appelle + `DestroySessionObject`. C'est ce qui a éliminé les `code:15 Request aborted` + à grande échelle (la réutilisation du même objet sur des centaines de + recalculs forçait l'Engine à recalculer en boucle). +4. **Fallbacks code 15** : si un lot trop large déclenche un `code:15`, on + retente automatiquement avec une taille plus petite (cf. + `QLIK_ARTICLE_BATCH_FALLBACKS`) sans tout annuler. + +## Nouveautés (cette itération) + +1. **Fenêtre paramétrable `QLIK_SYNC_MONTHS_BACK`** (1..12, défaut 12). Permet + de scoper un sync test à 6 mois au lieu de 12, ou de n'extraire que les 3 + derniers mois pour un check rapide. Le helper `envMonthsBack()` (dans + `src/lib/qlik-date-range.ts`) clamp la valeur et applique le défaut si + l'env est mal configurée. +2. **Filtre strict des codes centraux** dans `route.ts` (regex `[A-Z0-9][A-Z0-9_-]{1,29}`, + longueur 2..30, exclusion de `""`, `"-"`, `"_"`). Réduit les erreurs Engine + sur des valeurs parasites et le gaspillage réseau. Le log expose le **nombre** + de codes rejetés mais pas les valeurs (hygiène logs). +3. **Résumé timing final** : ligne unique `[qlik-pw][summary] rows=… months=… + batches=… code15Retries=… fallbackCount=… totalMs=…` émise en fin de sync + pour audit/debug sans avoir à parser les logs détaillés par lot. + +## Exemple de `.env.local` + +```env +# Sync Qlik : fenêtre par défaut 12 mois ; on peut descendre à 6 pour un +# test rapide ou un check hebdo sans recharger tout l'historique. +QLIK_SYNC_MONTHS_BACK=12 + +# Batch sizes / fallbacks (laisser par défaut sauf contre-indication). +QLIK_ARTICLE_BATCH_SIZE=150 +QLIK_ARTICLE_BATCH_FALLBACKS=75,50 +QLIK_SETTLE_MS=150 +QLIK_CODE15_MAX_ATTEMPTS=4 +``` + +## Tests + +```bash +# Tests purs Node (aucune dépendance Next.js). +npx tsx scripts/test-qlik-date-range.ts +``` + +Couvre : +- repères Qlik (sérial ↔ ISO), +- fenêtre 12 mois (référence), +- `getMonthRanges()` : 12 mois / fenêtre partielle / année bissextile, +- option `monthsBack` : 6 mois + clamp 0 / -5 / 24 / NaN / undefined, +- helper `envMonthsBack()` : env absent / vide / valide / hors bornes / + non numérique / décimal / espaces. + +## Référence + +- `src/lib/qlik-date-range.ts` — fenêtre, `envMonthsBack`, `getMonthRanges`. +- `src/lib/qlik-playwright.ts` — extracteur Playwright, hypercube jetable, + fallbacks code 15, `[qlik-pw][summary]` final. +- `src/app/api/qlik/sync/route.ts` — route admin, `filterCentralCodes`, + applique `QLIK_SYNC_MONTHS_BACK` via `envMonthsBack`. +- `scripts/test-qlik-date-range.ts` — tests CLI. +- `docs/qlik-integration.md` — doc d'intégration générale (auth NTLM, + discovery, hypercube, etc.). diff --git a/scripts/test-qlik-date-range.ts b/scripts/test-qlik-date-range.ts index ff29eb3..43876e3 100644 --- a/scripts/test-qlik-date-range.ts +++ b/scripts/test-qlik-date-range.ts @@ -9,6 +9,8 @@ * label=2025-06_2026-05, 365 serials) * - exemples supplémentaires (1er du mois, fin d'année, année bissextile) * - erreurs attendues sur entrée mal formée + * - option `monthsBack` : 6 mois, bornes (clamp 1..12) + * - helper `envMonthsBack` : valeur absente, valide, hors bornes, non numérique * * Aucun framework : assertions manuelles, exit code 0/1. */ @@ -19,7 +21,11 @@ import { buildGridNetworkQlikDateFilter, chunkSerials, getMonthRanges, + envMonthsBack, QLIK_DATE_ANCHORS, + QLIK_MONTHS_BACK_MIN, + QLIK_MONTHS_BACK_MAX, + QLIK_MONTHS_BACK_DEFAULT, } from "../src/lib/qlik-date-range"; let pass = 0; @@ -142,5 +148,47 @@ eq("3 mois (juin, juillet, août)", monthsShort.length, 3); eq("août tronqué à 15j", monthsShort[2].dailySerials.length, 15); eq("août dateFin = 2025-08-15", monthsShort[2].dateFin, "2025-08-15"); +console.log("\n[10] monthsBack=6 (now=2026-06-21) → 2025-12-01 .. 2026-05-31"); +const f6 = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 6); +eq("dateDebut", f6.dateDebut, "2025-12-01"); +eq("dateFin", f6.dateFin, "2026-05-31"); +eq("qStart", f6.qStart, 45992); +eq("qEnd", f6.qEnd, 46173); +eq("label", f6.label, "2025-12_2026-05"); +eq("dailySerials.length = 182 (déc 2025:31 + jan:31 + fév:28 + mar:31 + avr:30 + mai:31)", f6.dailySerials.length, 182); +const months6 = getMonthRanges(f6); +eq("getMonthRanges: 6 mois exactement", months6.length, 6); +eq("premier mois label", months6[0].label, "2025-12"); +eq("dernier mois label", months6[5].label, "2026-05"); +const totalDays6 = months6.reduce((acc, m) => acc + m.dailySerials.length, 0); +eq("somme des jours = 182", totalDays6, 182); + +console.log("\n[11] monthsBack — bornes et clamp"); +eq("monthsBack=1 (now=2026-06-21) → 2026-05-01 .. 2026-05-31 (1 mois complet)", + (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 1); return x.dateDebut === "2026-05-01" && x.dateFin === "2026-05-31"; })(), + true); +eq("monthsBack=0 clampé à 1", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 0); return x.dateDebut === "2026-05-01"; })(), true); +eq("monthsBack=24 clampé à 12 (max)", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 24); return x.dateDebut === "2025-06-01" && x.dateFin === "2026-05-31"; })(), true); +eq("monthsBack=-5 clampé à 1", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), -5); return x.dateDebut === "2026-05-01"; })(), true); +eq("monthsBack=NaN ramené au défaut 12", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), Number.NaN); return x.dateDebut === "2025-06-01" && x.dateFin === "2026-05-31"; })(), true); +eq("monthsBack=undefined (par défaut) = défaut 12", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z")); return x.dateDebut === "2025-06-01" && x.dateFin === "2026-05-31"; })(), true); +eq("bornes exportées MIN=1", QLIK_MONTHS_BACK_MIN, 1); +eq("bornes exportées MAX=12", QLIK_MONTHS_BACK_MAX, 12); +eq("défaut exporté = 12", QLIK_MONTHS_BACK_DEFAULT, 12); + +console.log("\n[12] envMonthsBack — lecture env"); +const PREV = process.env.QLIK_TEST_MONTHS_BACK; +delete process.env.QLIK_TEST_MONTHS_BACK; +eq("env absent → défaut", envMonthsBack("QLIK_TEST_MONTHS_BACK"), QLIK_MONTHS_BACK_DEFAULT); +eq("env vide → défaut", (() => { process.env.QLIK_TEST_MONTHS_BACK = ""; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_DEFAULT); +eq("env '6' → 6", (() => { process.env.QLIK_TEST_MONTHS_BACK = "6"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), 6); +eq("env 'abc' → défaut", (() => { process.env.QLIK_TEST_MONTHS_BACK = "abc"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_DEFAULT); +eq("env '24' clampé à MAX=12", (() => { process.env.QLIK_TEST_MONTHS_BACK = "24"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_MAX); +eq("env '-3' clampé à MIN=1", (() => { process.env.QLIK_TEST_MONTHS_BACK = "-3"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_MIN); +eq("env '0' clampé à MIN=1", (() => { process.env.QLIK_TEST_MONTHS_BACK = "0"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_MIN); +eq("env '3.7' troncé à 3", (() => { process.env.QLIK_TEST_MONTHS_BACK = "3.7"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), 3); +eq("env ' 8 ' trimé puis parsé → 8", (() => { process.env.QLIK_TEST_MONTHS_BACK = " 8 "; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), 8); +if (PREV === undefined) delete process.env.QLIK_TEST_MONTHS_BACK; else process.env.QLIK_TEST_MONTHS_BACK = PREV; + console.log(`\n=== ${pass} passés, ${fail} échoués ===`); if (fail > 0) process.exit(1); diff --git a/src/app/api/qlik/sync/route.ts b/src/app/api/qlik/sync/route.ts index 3959369..654dc68 100644 --- a/src/app/api/qlik/sync/route.ts +++ b/src/app/api/qlik/sync/route.ts @@ -3,7 +3,13 @@ import { auth } from "@/lib/auth"; import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright"; import { upsertNetworkMetrics } from "@/lib/qlik-network-cache"; import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client"; -import { buildGridNetworkQlikDateFilter } from "@/lib/qlik-date-range"; +import { + buildGridNetworkQlikDateFilter, + envMonthsBack, + QLIK_MONTHS_BACK_DEFAULT, + QLIK_MONTHS_BACK_MAX, + QLIK_MONTHS_BACK_MIN, +} from "@/lib/qlik-date-range"; // Tâche d'extraction Qlik potentiellement très longue (hypercube paginé). // On accepte quand même 5 min côté plateforme Next.js, mais on rend la main au @@ -99,6 +105,68 @@ async function requireAdmin(): Promise { return null; } +/** + * Regex de validation d'un code centrale "raisonnable". + * + * Format attendu d'après l'API FF : préfixe `10000` + 6 chiffres (ex `10000167303`), + * donc 11 caractères alphanumériques. Pour rester large et ne pas casser + * d'éventuels codes alphanumériques fournisseur (refs internes), on autorise + * - longueur 2..30 + * - jeu `[A-Z0-9_-]` + * - premier caractère alphanumérique (pas `_` ni `-`) + * + * But : éviter d'envoyer à Qlik des chaînes vides, des `-`, des espaces + * parasites, ou des entrées manifestement invalides qui déclenchent des + * codes d'erreur Engine ou du gaspillage réseau. **Volontairement simple** : + * on ne touche pas au mapping ; on filtre seulement ce qui ne ressemble à + * aucun code plausible. + */ +const CENTRAL_CODE_REGEX = /^[A-Z0-9][A-Z0-9_-]{1,29}$/i; + +/** + * Filtre strict des codes centraux avant d'envoyer à Qlik. + * - trim + drop vides + * - drop "-" (placeholder déjà exclu côté agrégation mais on double-check) + * - drop doublons (préserve l'ordre, comparaison insensible à la casse) + * - drop codes ne matchant pas CENTRAL_CODE_REGEX (longueur / charset) + * + * Renvoie `{ accepted, rejected, deduped }`. Les logs n'exposent que les + * compteurs, jamais les valeurs des codes. + */ +function filterCentralCodes(rawCodes: Array): { + accepted: string[]; + rejected: number; + deduped: number; +} { + const seen = new Set(); + const accepted: string[] = []; + let rejected = 0; + let deduped = 0; + for (const raw of rawCodes) { + if (raw == null) { + rejected++; + continue; + } + const code = String(raw).trim(); + const key = code.toUpperCase(); + if (!code || code === "-" || code === "_") { + rejected++; + continue; + } + if (seen.has(key)) { + deduped++; + continue; + } + if (!CENTRAL_CODE_REGEX.test(code)) { + rejected++; + continue; + } + seen.add(key); + accepted.push(code); + } + return { accepted, rejected, deduped }; +} + /** * Exécute réellement l'extraction + l'upsert, en mettant à jour le job * au fur et à mesure (compteur fetched, puis upserted, puis finishedAt). @@ -106,30 +174,31 @@ async function requireAdmin(): Promise { async function runJob(job: QlikSyncJob): Promise { try { const articles = await pgGetArticlesByFournisseur(job.fournisseur); - const codes = [ - ...new Set( - articles - .map((a) => (a.codeCentrale ? String(a.codeCentrale).trim() : "")) - .filter(Boolean), - ), - ]; + // Filtre strict des codes (trim, dedupe, exclusion vides/`-`/codes trop suspects). + const { accepted: codes, rejected: codesRejected, deduped: codesDeduped } = filterCentralCodes( + articles.map((a) => a.codeCentrale), + ); job.requested = codes.length; console.log( - `[api/qlik/sync] job=${job.jobId} fournisseur=${job.fournisseur} — ${articles.length} articles, ${codes.length} codes centraux uniques. Échantillon: ${JSON.stringify(codes.slice(0, 5))}`, + `[api/qlik/sync] job=${job.jobId} fournisseur=${job.fournisseur} — ${articles.length} articles, ${codes.length} codes centraux retenus, ${codesRejected} rejetés (vide/-/anormaux), ${codesDeduped} doublons supprimés`, ); if (codes.length === 0) { job.status = "success"; - job.message = "Aucun article avec code centrale pour ce fournisseur"; + job.message = "Aucun code centrale valide pour ce fournisseur"; job.finishedAt = new Date().toISOString(); return; } - const dateFilter = buildGridNetworkQlikDateFilter(); + // Fenêtre temporelle alignée sur la grille, éventuellement raccourcie via + // QLIK_SYNC_MONTHS_BACK (1..12, défaut 12). On logue la valeur effective + // pour audit. + const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT); + const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack); job.periode = dateFilter.label; job.dateDebut = dateFilter.dateDebut; job.dateFin = dateFilter.dateFin; console.log( - `[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — période ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`, + `[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — fenêtre ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin}, QLIK_SYNC_MONTHS_BACK=${monthsBack}, bornes ${QLIK_MONTHS_BACK_MIN}..${QLIK_MONTHS_BACK_MAX})…`, ); const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter); job.fetched = metrics.size; diff --git a/src/lib/qlik-date-range.ts b/src/lib/qlik-date-range.ts index e9a715c..e1eff67 100644 --- a/src/lib/qlik-date-range.ts +++ b/src/lib/qlik-date-range.ts @@ -2,7 +2,7 @@ * CollectFlow — Helper de fenêtre temporelle pour Qlik réseau. * * Aligne la timeline d'extraction Qlik réseau sur la timeline de la grille : - * - 12 mois complets glissants + * - `monthsBack` mois complets glissants (défaut : 12) * - exclut systématiquement le mois courant * * Référence utilisée : `buildLast12MonthsRange()` de `api-ff-client.ts` @@ -33,6 +33,34 @@ * Pas de secrets ici. Pure fonction utilitaire testable en CLI Node. */ +/** + * Bornes acceptées pour `monthsBack`. On reste sur 1..12 pour ne pas casser + * la sémantique "12 mois glissants alignés sur la grille" : une valeur plus + * grande n'apporterait pas de signal réseau supplémentaire (la grille + * n'expose elle-même que ~12 mois). En-deçà de 1 mois, la fenêtre serait + * vide (mois courant exclu) ou incohérente. + */ +export const QLIK_MONTHS_BACK_MIN = 1; +export const QLIK_MONTHS_BACK_MAX = 12; +export const QLIK_MONTHS_BACK_DEFAULT = 12; + +/** + * Lit `process.env[name]` et renvoie un entier borné entre `min` et `max`. + * Renvoie `defaultValue` si la variable est absente / vide / non numérique. + * + * Volontairement exportée pour être réutilisée par `qlik-playwright.ts` et + * `sync/route.ts` (cohérence avec `envNumber` côté Playwright, mais en pur + * Node sans dépendance Next.js). + */ +export function envMonthsBack(name: string, defaultValue: number = QLIK_MONTHS_BACK_DEFAULT): number { + const raw = process.env[name]; + if (raw == null || raw.trim() === "") return defaultValue; + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return defaultValue; + const clamped = Math.min(QLIK_MONTHS_BACK_MAX, Math.max(QLIK_MONTHS_BACK_MIN, Math.trunc(parsed))); + return clamped; +} + /** Epoch Qlik = 1899-12-30 (équivalent Excel/Lotus 1-2-3). */ const QLIK_EPOCH_MS = Date.UTC(1899, 11, 30); // 1899-12-30T00:00:00Z @@ -91,17 +119,30 @@ export interface QlikDateFilter { } /** - * Calcule la fenêtre 12 mois complets (excluant mois courant). - * Par défaut, utilise `now = new Date()`. Testable via injection de `now`. + * Calcule la fenêtre `monthsBack` mois complets (excluant mois courant). + * Par défaut, utilise `now = new Date()` et `monthsBack = 12`. * - * Logique alignée sur `buildLast12MonthsRange()` de `src/lib/api-ff-client.ts`. - * Si la source officielle change, mettre à jour ici aussi (garder en sync). + * Logique alignée sur `buildLast12MonthsRange()` de `src/lib/api-ff-client.ts` + * (défaut = 12 mois). Si la source officielle change, mettre à jour ici aussi + * (garder en sync). + * + * Bornes : `monthsBack` est clampé dans `[QLIK_MONTHS_BACK_MIN, QLIK_MONTHS_BACK_MAX]` + * (1..12). Une valeur invalide (NaN, ≤ 0, > 12) est ramenée aux bornes sans + * exception — on veut que la sync reste possible même si l'env est mal + * configuré. */ -export function buildGridNetworkQlikDateFilter(now: Date = new Date()): QlikDateFilter { +export function buildGridNetworkQlikDateFilter( + now: Date = new Date(), + monthsBack: number = QLIK_MONTHS_BACK_DEFAULT, +): QlikDateFilter { + const months = Math.min( + QLIK_MONTHS_BACK_MAX, + Math.max(QLIK_MONTHS_BACK_MIN, Math.trunc(Number.isFinite(monthsBack) ? monthsBack : QLIK_MONTHS_BACK_DEFAULT)), + ); // Fin : dernier jour du mois précédent le mois courant const endMonth = new Date(now.getFullYear(), now.getMonth(), 0); - // Début : 12 mois avant le mois courant (1er du mois) - const startMonth = new Date(now.getFullYear(), now.getMonth() - 12, 1); + // Début : `months` mois avant le mois courant (1er du mois) + const startMonth = new Date(now.getFullYear(), now.getMonth() - months, 1); const dateDebut = startMonth.toISOString().slice(0, 10); const dateFin = endMonth.toISOString().slice(0, 10); diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index 1ecdbec..d779fed 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -31,7 +31,47 @@ async function getBrowser(): Promise { return browserPromise; } -interface InPageResult { ok: boolean; rows?: Array>; size?: number; error?: string; diag?: Record; } +interface InPageResult { + ok: boolean; + rows?: Array>; + size?: number; + error?: string; + diag?: Record; + // Compteurs agrégés pour le résumé timing final côté Node (cf. qlik-playwright). + months?: number; + batches?: number; + code15Retries?: number; + fallbackCount?: number; +} + +function envNumber(name: string, defaultValue: number, min: number, max: number): number { + const raw = process.env[name]; + if (raw == null || raw.trim() === "") return defaultValue; + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return defaultValue; + return Math.min(max, Math.max(min, Math.trunc(parsed))); +} + +function envNumberCsv(name: string, min: number, max: number): number[] { + const raw = process.env[name]; + if (!raw) return []; + return raw + .split(",") + .map((part) => Number(part.trim())) + .filter((value) => Number.isFinite(value)) + .map((value) => Math.min(max, Math.max(min, Math.trunc(value)))); +} + +function uniqueOrdered(values: number[]): number[] { + const seen = new Set(); + const out: number[] = []; + for (const value of values) { + if (seen.has(value)) continue; + seen.add(value); + out.push(value); + } + return out; +} export async function fetchNetworkMetricsPlaywright( codeCentraux?: string[], @@ -41,6 +81,8 @@ export async function fetchNetworkMetricsPlaywright( if (!cfg.appNetwork) throw new Error("[qlik-pw] QLIK_APP_NETWORK manquant"); if (!cfg.user || !cfg.password) throw new Error("[qlik-pw] identifiants Qlik manquants"); + // Démarre l'horloge pour le résumé timing final (cf. bloc [qlik-pw][summary]). + const totalStart = Date.now(); console.log(`[qlik-pw] host=${cfg.host} app=${cfg.appNetwork} — ${(codeCentraux ?? []).length} codes à sélectionner`); if (dateFilter) { console.log(`[qlik-pw] filtre Date=${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin}, ${dateFilter.setAnalysis})`); @@ -49,6 +91,12 @@ export async function fetchNetworkMetricsPlaywright( console.log(`[qlik-pw] session NTLM OK, cookie=${sess.cookie.split("=")[0]}=…`); const [cookieName, ...rest] = sess.cookie.split("="); const cookieValue = rest.join("="); + const qlikSettleMs = envNumber("QLIK_SETTLE_MS", 150, 0, 2000); + const qlikCode15MaxAttempts = envNumber("QLIK_CODE15_MAX_ATTEMPTS", 4, 1, 8); + const qlikArticleBatchSize = envNumber("QLIK_ARTICLE_BATCH_SIZE", 150, 1, 500); + const csvFallbacks = envNumberCsv("QLIK_ARTICLE_BATCH_FALLBACKS", 1, 500); + const qlikArticleBatchFallbacks = uniqueOrdered([qlikArticleBatchSize, ...(csvFallbacks.length ? csvFallbacks : [75, 50]), 75, 50]); + console.log(`[qlik-pw] tuning settle=${qlikSettleMs}ms code15Attempts=${qlikCode15MaxAttempts} batchFallbacks=${qlikArticleBatchFallbacks.join(">")}`); const browser = await getBrowser(); console.log(`[qlik-pw] chromium lancé`); @@ -89,11 +137,14 @@ export async function fetchNetworkMetricsPlaywright( })); const result = (await page.evaluate( - ({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode }: { + ({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks }: { app: string; dim: string; mca: string; mqte: string; mnb: string; mcamag: string; mmarge: string; codes: string[]; token: string; monthlyPayload: Array<{ label: string; dateDebut: string; dateFin: string; serials: number[] }>; noDateMode: boolean; + qlikSettleMs: number; + qlikCode15MaxAttempts: number; + qlikArticleBatchFallbacks: number[]; }) => new Promise((resolve) => { const loc = (window as unknown as { location: Location }).location; @@ -110,7 +161,12 @@ export async function fetchNetworkMetricsPlaywright( ws.onmessage = (ev: MessageEvent) => { const m = JSON.parse(ev.data as string); if (m.method === "OnConnected") return res(); - if (m.id && pend.has(m.id)) { const x = pend.get(m.id)!; pend.delete(m.id); m.error ? x.rej(new Error(JSON.stringify(m.error))) : x.res(m.result); } + if (m.id && pend.has(m.id)) { + const x = pend.get(m.id)!; + pend.delete(m.id); + if (m.error) x.rej(new Error(JSON.stringify(m.error))); + else x.res(m.result); + } }; ws.onerror = () => rej(new Error("ws error (403 ?)")); setTimeout(() => rej(new Error("ws timeout")), 30000); @@ -124,22 +180,34 @@ export async function fetchNetworkMetricsPlaywright( return /"code"\s*:\s*15\b/.test(msg); }; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + const nowMs = () => (typeof performance !== "undefined" ? performance.now() : Date.now()); + const timed = async (etape: string, method: string, fn: () => Promise, timings?: Record): Promise<{ value: T; ms: number }> => { + const start = nowMs(); + const value = await fn(); + const ms = Math.round(nowMs() - start); + const label = `${etape}:${method}`; + if (timings) timings[label] = (timings[label] ?? 0) + ms; + console.log(`[qlik-pw][timing] ${label} ms=${ms}`); + return { value, ms }; + }; + const timingSummary = (timings: Record) => JSON.stringify(timings); const rpcWithRetry = async ( etape: string, method: string, params: unknown, handle: number, - maxAttempts = 4, + onCode15Retry?: () => void, ): Promise<{ [k: string]: unknown }> => { let lastErr: unknown; - for (let attempt = 1; attempt <= maxAttempts; attempt++) { + for (let attempt = 1; attempt <= qlikCode15MaxAttempts; attempt++) { try { return await rpc(method, params, handle); } catch (e) { lastErr = e; - if (!isEngineCode15(e) || attempt === maxAttempts) throw e; - const backoff = 150 * attempt; - console.log(`[qlik-pw] retry code15 étape=${etape} méthode=${method} tentative=${attempt}/${maxAttempts} backoff=${backoff}ms`); + if (!isEngineCode15(e) || attempt === qlikCode15MaxAttempts) throw e; + onCode15Retry?.(); + const backoff = qlikSettleMs * attempt; + console.log(`[qlik-pw] retry code15 étape=${etape} méthode=${method} tentative=${attempt}/${qlikCode15MaxAttempts} backoff=${backoff}ms`); await sleep(backoff); } } @@ -180,6 +248,11 @@ export async function fetchNetworkMetricsPlaywright( // causé par la réutilisation du même objet sur des centaines de recalculs. const out: Array> = []; + // Compteurs agrégés pour le résumé timing final (loggué côté Node après la sync). + let aggMonths = 0; + let aggBatches = 0; + let aggCode15Retries = 0; + let aggFallbackCount = 0; // Sélectionne Date + Article Code puis pagine le cube. Renvoie le nb de lignes // brutes lues (avant dédoublonnage côté Node). @@ -190,116 +263,155 @@ export async function fetchNetworkMetricsPlaywright( const PAGE = 1400; // Petite latence après chaque mutation de sélection : laisser l'Engine // finaliser l'évaluation de la sélection avant la prochaine requête layout/cube. - const SETTLE_MS = 150; - const createCubeForBatch = async (): Promise<{ handle: number; id: string }> => { - const obj = await rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net" }, qHyperCubeDef: { + const createCubeForBatch = async (timings?: Record, onCode15Retry?: () => void): Promise<{ handle: number; id: string }> => { + const { value: obj } = await timed("createCube", "CreateSessionObject", () => rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net" }, qHyperCubeDef: { qDimensions: [{ qLibraryId: dim }], qMeasures: [{ qLibraryId: mca }, { qLibraryId: mqte }, { qLibraryId: mnb }, { qLibraryId: rCamag }, { qLibraryId: rMarge }], - qInitialDataFetch: [], - } } }, doc); + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 6, qHeight: PAGE }], + } } }, doc, onCode15Retry), timings); const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; return { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; }; - const destroyCube = async (cube: { handle: number; id: string }) => { + const destroyCube = async (cube: { handle: number; id: string }, timings?: Record) => { if (!cube.id) return; try { - await rpcWithRetry("destroyCube", "DestroySessionObject", { qId: cube.id }, doc); + await timed("destroyCube", "DestroySessionObject", () => rpcWithRetry("destroyCube", "DestroySessionObject", { qId: cube.id }, doc), timings); } catch (e) { // Non fatal : l'objet est de toute façon scopé à la session websocket. // Un code 15 ici signifie que l'Engine l'a déjà libéré après un abort. console.log("[qlik-pw] DestroySessionObject ignoré: " + String((e as Error)?.message || e)); } }; - const fetchCubeForSelection = async (sink: Array>): Promise => { - const cube = await createCubeForBatch(); + const appendMatrix = (matrix: Array>, sink: Array>): number => { + for (const r of matrix) sink.push([ + String(r[0]?.qText ?? ""), + Number(r[1]?.qNum) || 0, + Number(r[2]?.qNum) || 0, + Number(r[3]?.qNum) || 0, + Number(r[4]?.qNum) || 0, + Number(r[5]?.qNum) || 0, + ]); + return matrix.length; + }; + const fetchCubeForSelection = async (sink: Array>, timings?: Record, onCode15Retry?: () => void): Promise => { + const cube = await createCubeForBatch(timings, onCode15Retry); const oh = cube.handle; try { - const layout = await rpcWithRetry("getLayout", "GetLayout", {}, oh); - const size = ((layout.qLayout as { qHyperCube: { qSize: { qcy: number } } }).qHyperCube).qSize.qcy; + const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings); + const hyperCube = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array> }> } }).qHyperCube; + const size = hyperCube.qSize.qcy; + const firstPage = hyperCube.qDataPages?.find((page) => (page.qArea?.qTop ?? 0) === 0); let got = 0; - for (let top = 0; top < size; top += PAGE) { - const d = await rpcWithRetry("getHyperCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 6, qHeight: PAGE }] }, oh); + let top = 0; + if (firstPage?.qMatrix?.length) { + got += appendMatrix(firstPage.qMatrix, sink); + top = PAGE; + } + for (; top < size; top += PAGE) { + const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 6, qHeight: PAGE }] }, oh, onCode15Retry), timings); const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; if (!matrix.length) break; - for (const r of matrix) sink.push([ - String(r[0]?.qText ?? ""), - Number(r[1]?.qNum) || 0, - Number(r[2]?.qNum) || 0, - Number(r[3]?.qNum) || 0, - Number(r[4]?.qNum) || 0, - Number(r[5]?.qNum) || 0, - ]); - got += matrix.length; + got += appendMatrix(matrix, sink); if (matrix.length < PAGE) break; } return got; } finally { - await destroyCube(cube); + await destroyCube(cube, timings); } }; if (noDateMode) { // Aucun filtre Date : extraction en un seul appel (tous les codes d'un coup). // On sélectionne uniquement Article Code ; Date reste libre. + const timings: Record = {}; + let code15Retries = 0; console.log("mode sans dateFilter — sélection de " + codes.length + " codes en une fois"); if (codes.length && fh !== -1) { - const sel = await rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh); + const { value: sel } = await timed("selectCode", "SelectValues", () => rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh), timings); console.log("SelectValues Article Code → " + JSON.stringify(sel.qReturn)); } - const got = await fetchCubeForSelection(out); - console.log("extraction sans date — " + got + " ligne(s), cumul=" + out.length); + const batchRows: Array> = []; + const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, timings, () => { code15Retries++; }), timings); + out.push(...batchRows); + aggMonths = 1; + aggBatches = 1; + aggCode15Retries = code15Retries; + console.log("[qlik-pw][timing] noDate rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); } else { // Itération par mois × lots de codes : pour chaque mois, SelectValues Date // (~30 serials), puis SelectValues Article Code par petits lots. Le cube - // reste borné à ~30 jours × 50 codes, sans lire tout le catalogue Qlik. + // reste borné à ~30 jours × batch contrôlé, sans lire tout le catalogue Qlik. const total = monthlyPayload.length; - const ARTICLE_BATCH = 50; - const codeBatches: string[][] = []; - if (codes.length) { - for (let i = 0; i < codes.length; i += ARTICLE_BATCH) codeBatches.push(codes.slice(i, i + ARTICLE_BATCH)); - } else { - // Comportement historique si aucun code n'est fourni : lire le mois complet. - codeBatches.push([]); - } - console.log("itération par mois — " + total + " mois × " + codeBatches.length + " lot(s) de ≤" + ARTICLE_BATCH + " codes"); + const fallbackSizes = qlikArticleBatchFallbacks.length ? qlikArticleBatchFallbacks : [150, 75, 50]; + const fallbackLabel = fallbackSizes.join(">"); + const estimatedBatches = codes.length ? Math.ceil(codes.length / fallbackSizes[0]) : 1; + console.log("itération par mois — " + total + " mois × ~" + estimatedBatches + " lot(s), batchFallbacks=" + fallbackLabel); for (let mi = 0; mi < total; mi++) { const m = monthlyPayload[mi]; const prefix = "[qlik-pw] mois " + (mi + 1) + "/" + total + " " + m.label + " (" + m.dateDebut + "→" + m.dateFin + ", " + m.serials.length + "j)"; + const monthStart = nowMs(); + const monthTimings: Record = {}; + aggMonths = mi + 1; try { // 1) Sélection Date pour le mois (un seul SelectValues, ~30 valeurs). - const dsel = await rpc("SelectValues", { + const { value: dsel } = await timed("selectDate", "SelectValues", () => rpc("SelectValues", { qFieldValues: m.serials.map((s) => ({ qNum: s, qText: String(s) })), qToggleMode: false, qSoftLock: true, - }, dfh); + }, dfh), monthTimings); console.log(prefix + " — SelectValues Date " + m.serials.length + " serials → " + JSON.stringify(dsel.qReturn)); // Laisser l'Engine finaliser l'évaluation de la sélection Date // avant la première SelectValues Article Code du 1er lot. - await sleep(SETTLE_MS); + await sleep(qlikSettleMs); // 2) Sélection Article Code par lots contrôlés. - for (let bi = 0; bi < codeBatches.length; bi++) { - const batch = codeBatches[bi]; - const batchPrefix = prefix + " — lot " + (bi + 1) + "/" + codeBatches.length + " (" + batch.length + " codes)"; + let codeIndex = 0; + let batchNumber = 0; + let fallbackIndex = 0; + const totalCodes = codes.length || 0; + while (codes.length ? codeIndex < totalCodes : batchNumber === 0) { + const batchSize = codes.length ? fallbackSizes[fallbackIndex] : 0; + const batch = codes.length ? codes.slice(codeIndex, Math.min(codeIndex + batchSize, totalCodes)) : []; + const batchStart = codeIndex; + const batchEnd = codeIndex + batch.length; + const timings: Record = {}; + let code15Retries = 0; + batchNumber++; + const batchPrefix = prefix + " — lot " + batchNumber + " [" + batchStart + "," + batchEnd + ") size=" + batch.length; try { if (fh !== -1) { - await rpc("Clear", {}, fh); + await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); // Laisser l'Engine appliquer le Clear avant la nouvelle sélection. - await sleep(SETTLE_MS); - const csel = await rpc("SelectValues", { + await sleep(qlikSettleMs); + const { value: csel } = await timed("selectCode", "SelectValues", () => rpc("SelectValues", { qFieldValues: batch.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true, - }, fh); + }, fh), timings); console.log(batchPrefix + " — SelectValues Article Code → " + JSON.stringify(csel.qReturn)); // Laisser l'Engine finaliser l'évaluation de la sélection // avant de créer / interroger le cube (évite code 15 sur le 1er lot). - await sleep(SETTLE_MS); + await sleep(qlikSettleMs); } - const got = await fetchCubeForSelection(out); - console.log(batchPrefix + " — " + got + " ligne(s), cumul=" + out.length); + const batchRows: Array> = []; + const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, timings, () => { code15Retries++; }), timings); + out.push(...batchRows); + aggBatches++; + aggCode15Retries += code15Retries; + console.log("[qlik-pw][timing] " + batchPrefix + " — rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); + if (!codes.length) break; + codeIndex = batchEnd; + fallbackIndex = 0; } catch (batchErr) { const msg = String((batchErr as Error)?.message || batchErr); + const nextFallbackIndex = fallbackIndex + 1; + const nextSize = fallbackSizes[nextFallbackIndex]; + if (codes.length && isEngineCode15(batchErr) && nextSize && batch.length > nextSize) { + console.log("[qlik-pw][timing] " + batchPrefix + " — fallback code15 " + batch.length + "→" + nextSize + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); + fallbackIndex = nextFallbackIndex; + aggFallbackCount++; + continue; + } console.error(batchPrefix + " — ABORT — " + msg); throw new Error(batchPrefix + " — " + msg); } @@ -312,17 +424,18 @@ export async function fetchNetworkMetricsPlaywright( // 3) Clear Date + Article Code avant le mois suivant (soulage l'Engine). if (mi < total - 1) { try { - await rpc("Clear", {}, dfh); - if (fh !== -1) await rpc("Clear", {}, fh); + await timed("clearDate", "Clear", () => rpc("Clear", {}, dfh), monthTimings); + if (fh !== -1) await timed("clearCodeMonth", "Clear", () => rpc("Clear", {}, fh), monthTimings); } catch (clearErr) { console.log(prefix + " — Clear ignoré: " + String((clearErr as Error)?.message || clearErr)); } } + console.log("[qlik-pw][timing] " + prefix + " — done ms=" + Math.round(nowMs() - monthStart) + " cumul=" + out.length + " timings=" + timingSummary(monthTimings)); } } ws.close(); - resolve({ ok: true, rows: out, size: out.length }); + resolve({ ok: true, rows: out, size: out.length, months: aggMonths, batches: aggBatches, code15Retries: aggCode15Retries, fallbackCount: aggFallbackCount }); } catch (e) { try { ws.close(); } catch { /* noop */ } fail(String((e as Error)?.message || e)); } })(); }), @@ -338,11 +451,22 @@ export async function fetchNetworkMetricsPlaywright( token: csrfToken, monthlyPayload, noDateMode: !dateFilter, + qlikSettleMs, + qlikCode15MaxAttempts, + qlikArticleBatchFallbacks, }, )) as InPageResult; if (!result.ok) throw new Error(`[qlik-pw] ${result.error}`); + // Résumé timing final : synthèse compacte de la sync pour audit/debug. + // Les logs détaillés par étape restent en place (timings batch/mois) ; + // ici on consolide les compteurs agrégés et la durée totale. + const totalMs = Date.now() - totalStart; + console.log( + `[qlik-pw][summary] rows=${result.size ?? 0} months=${result.months ?? 0} batches=${result.batches ?? 0} code15Retries=${result.code15Retries ?? 0} fallbackCount=${result.fallbackCount ?? 0} totalMs=${totalMs}`, + ); + const wanted = codeCentraux ? new Set(codeCentraux) : null; const out = new Map(); const margeWeight = new Map();