From 0e5c890b74f1c2f6ff2d494ea9612a48cc78ce0d Mon Sep 17 00:00:00 2001 From: louispaulb Date: Fri, 3 Jul 2026 20:42:18 -0400 Subject: [PATCH] =?UTF-8?q?perf(hub):=20cache=20liste=20techs=20(30s)=20+?= =?UTF-8?q?=20garde=20anti-chevauchement=20sync=20+=20jeton=20ingest=20m?= =?UTF-8?q?=C3=A9mo=C3=AFs=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch 2 de l'audit (perf backend), items sûrs : - roster.js : fetchTechnicians caché 30 s (appelé par endpoint + generate + materialize + tech-positions polling 25 s pour des données quasi statiques). invalidateTechCache() sur les 6 écritures Dispatch Technician (coût/effic/skills/pause/traccar/weekly) → pas de stale après édition. Vérifié : call1 71 ms → call2 5 ms (caché). - legacy-dispatch-sync.js : garde _inFlight sur le tick d'import ET la veille → un passage long ne se relance plus par-dessus (évitait doublons Dispatch Job + contention ERPNext). - conversation.js : jeton d'ingestion webhook mémoïsé (mailIngestToken) au lieu d'un fs.readFileSync SYNCHRONE à chaque courriel/canal entrant (bloquait la boucle d'événements). NON fait (par prudence) : paralléliser les erp.list séquentiels de generate() (séquencement DÉLIBÉRÉ anti-ECONNRESET Frappe, cf. feedback_hub_crash_502) ; batch N+1 Customer du triage (refactor risqué d'une chaîne de fallback stateful — reporté) ; jitter des pollers (502 boot déjà mitigé). Co-Authored-By: Claude Opus 4.8 --- services/targo-hub/lib/conversation.js | 19 ++++++++++----- .../targo-hub/lib/legacy-dispatch-sync.js | 8 +++++-- services/targo-hub/lib/roster.js | 23 ++++++++++++++----- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/services/targo-hub/lib/conversation.js b/services/targo-hub/lib/conversation.js index 46e7a59..a3eb617 100644 --- a/services/targo-hub/lib/conversation.js +++ b/services/targo-hub/lib/conversation.js @@ -14,6 +14,17 @@ const conversations = new Map() const pushSubscriptions = new Map() const agentTimers = new Map() +// Jeton d'ingestion webhook (courriel/canal) : env sinon fichier — lu UNE seule fois (mémoïsé) au lieu d'un +// fs.readFileSync SYNCHRONE à CHAQUE webhook entrant (bloquait la boucle d'événements). Redémarrage requis pour une rotation. +let _mailIngestToken +function mailIngestToken () { + if (_mailIngestToken === undefined) { + _mailIngestToken = (process.env.MAIL_INGEST_TOKEN || '').trim() + if (!_mailIngestToken) { try { _mailIngestToken = fs.readFileSync('/app/data/mail_ingest.token', 'utf8').trim() } catch (e) { _mailIngestToken = '' } } + } + return _mailIngestToken +} + function loadFromDisk () { try { if (!fs.existsSync(CONV_FILE)) return @@ -1252,20 +1263,16 @@ async function handle (req, res, method, p, url) { // INGESTION COURRIEL (Phase 3) — poussé par n8n (Gmail Trigger sur cc@) ou un poller. Protégé par X-Mail-Token (PAS Authentik). if (p === '/conversations/email-ingest' && method === 'POST') { - const fs = require('fs') const tok = req.headers['x-mail-token'] || '' - let want = (process.env.MAIL_INGEST_TOKEN || '').trim() - if (!want) { try { want = fs.readFileSync('/app/data/mail_ingest.token', 'utf8').trim() } catch (e) {} } + const want = mailIngestToken() if (!want || tok !== want) return json(res, 401, { error: 'Unauthorized' }) try { const b = await parseBody(req); const r = await ingestEmail(b || {}); return json(res, r.ok ? 200 : 500, r) } catch (e) { return json(res, 500, { error: e.message }) } } // INGESTION CANAL (web chat nouveau site / 3CX / Facebook) — webhook externe. Protégé par X-Mail-Token (même jeton que l'ingest courriel). if (p === '/conversations/channel-ingest' && method === 'POST') { - const fs = require('fs') const tok = req.headers['x-mail-token'] || '' - let want = (process.env.MAIL_INGEST_TOKEN || '').trim() - if (!want) { try { want = fs.readFileSync('/app/data/mail_ingest.token', 'utf8').trim() } catch (e) {} } + const want = mailIngestToken() if (!want || tok !== want) return json(res, 401, { error: 'Unauthorized' }) try { const b = await parseBody(req); const r = await channelIngest(b || {}); return json(res, r.ok ? 200 : 500, r) } catch (e) { return json(res, 500, { error: e.message }) } } diff --git a/services/targo-hub/lib/legacy-dispatch-sync.js b/services/targo-hub/lib/legacy-dispatch-sync.js index b27c7ce..005b6cb 100644 --- a/services/targo-hub/lib/legacy-dispatch-sync.js +++ b/services/targo-hub/lib/legacy-dispatch-sync.js @@ -1120,14 +1120,18 @@ function startSync () { if (!/^(on|1|true)$/i.test(String(process.env.LEGACY_DISPATCH_SYNC || ''))) { log('legacy-dispatch-sync: récurrence désactivée (poser LEGACY_DISPATCH_SYNC=on pour activer)'); return } if (!mysql) { log('legacy-dispatch-sync: mysql2 absent → pont inactif'); return } const minutes = Number(process.env.LEGACY_DISPATCH_SYNC_MIN) || 15 - const tick = () => sync({ dryRun: false }).catch(e => log('legacy-dispatch-sync tick error:', e.message)) + // Garde anti-chevauchement : si un import dépasse l'intervalle (beaucoup de tickets), NE PAS relancer par-dessus + // (sinon doublons Dispatch Job + contention ERPNext). On saute le tick tant que le précédent tourne. + let _syncInFlight = false + const tick = () => { if (_syncInFlight) { log('legacy-dispatch-sync: passage précédent encore en cours → tick sauté'); return } _syncInFlight = true; sync({ dryRun: false }).catch(e => log('legacy-dispatch-sync tick error:', e.message)).finally(() => { _syncInFlight = false }) } // 1er passage différé (laisse le boot se stabiliser), puis toutes les `minutes`. setTimeout(tick, 90 * 1000) _timer = setInterval(tick, minutes * 60 * 1000) log(`legacy-dispatch-sync: pont actif (toutes les ${minutes} min, staff ${TARGO_TECH_STAFF_ID})`) // VEILLE temps-réel (poll léger → SSE) : cadence courte, indépendante de l'import. const watchMin = Number(process.env.LEGACY_DISPATCH_WATCH_MIN) || 3 - const wtick = () => watchLegacy().catch(e => log('legacy-watch error:', e.message)) + let _watchInFlight = false + const wtick = () => { if (_watchInFlight) return; _watchInFlight = true; watchLegacy().catch(e => log('legacy-watch error:', e.message)).finally(() => { _watchInFlight = false }) } setTimeout(wtick, 60 * 1000) // amorçage du snapshot ~1 min après le boot _watchTimer = setInterval(wtick, watchMin * 60 * 1000) log(`legacy-watch: veille active (toutes les ${watchMin} min → SSE topic 'dispatch')`) diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index ca90e26..b67b975 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -301,7 +301,18 @@ async function retryWrite (fn, tries = 5) { } // ── Lecture des techniciens humains + compétences + indisponibilités ──────── +// Cache court de la liste des techs : appelée souvent (endpoint /roster/technicians, generate, materialize, tech-positions +// polling 25 s…) pour des données quasi statiques. TTL 30 s → coupe les hits ERPNext répétés. Staleness acceptable +// (les éditions inline sont optimistes côté UI, pas de re-fetch immédiat). invalidateTechCache() dispo après écriture. +let _techCache = { ts: 0, data: null } +function invalidateTechCache () { _techCache.ts = 0 } async function fetchTechnicians () { + if (_techCache.data && (nowMs() - _techCache.ts) < 30000) return _techCache.data + const data = await _fetchTechniciansRaw() + _techCache = { ts: nowMs(), data } + return data +} +async function _fetchTechniciansRaw () { const rows = await erp.list('Dispatch Technician', { filters: [['resource_type', '=', 'human']], fields: ['name', 'technician_id', 'full_name', 'status', 'color_hex', 'tech_group', 'efficiency', 'skills', @@ -1780,7 +1791,7 @@ async function handle (req, res, method, path, url) { const patch = { skills: (b.skills || '').trim() } if (b.skill_levels !== undefined) patch.skill_levels = typeof b.skill_levels === 'string' ? b.skill_levels : JSON.stringify(b.skill_levels || {}) // niveaux 1–5 par compétence (JSON) if (b.skill_eff !== undefined) patch.skill_eff = typeof b.skill_eff === 'string' ? b.skill_eff : JSON.stringify(b.skill_eff || {}) // efficacité (facteur vitesse) PAR compétence (JSON) - const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch)) + const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch)); if (r && r.ok) invalidateTechCache() return json(res, r.ok ? 200 : 500, { ...r, technician: techId }) } const mCost = path.match(/^\/roster\/technician\/(.+)\/cost$/) @@ -1788,7 +1799,7 @@ async function handle (req, res, method, path, url) { const techId = decodeURIComponent(mCost[1]); const b = await parseBody(req) const techName = await resolveTechName(techId) if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId }) - const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { cost_salary_h: Number(b.salary) || 0, cost_charges_pct: Number(b.charges) || 0, cost_other_h: Number(b.other) || 0 })) + const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { cost_salary_h: Number(b.salary) || 0, cost_charges_pct: Number(b.charges) || 0, cost_other_h: Number(b.other) || 0 })); if (r && r.ok) invalidateTechCache() return json(res, r.ok ? 200 : 500, { ...r, technician: techId }) } const mEff = path.match(/^\/roster\/technician\/(.+)\/efficiency$/) @@ -1796,7 +1807,7 @@ async function handle (req, res, method, path, url) { const techId = decodeURIComponent(mEff[1]); const b = await parseBody(req) const techName = await resolveTechName(techId) if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId }) - const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { efficiency: Number(b.efficiency) || 1 })) + const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { efficiency: Number(b.efficiency) || 1 })); if (r && r.ok) invalidateTechCache() return json(res, r.ok ? 200 : 500, { ...r, technician: techId, efficiency: Number(b.efficiency) || 1 }) } const mPause = path.match(/^\/roster\/technician\/(.+)\/pause$/) @@ -1808,7 +1819,7 @@ async function handle (req, res, method, path, url) { if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId }) const patch = { status: b.paused ? PAUSE_STATUS : AVAIL_STATUS } if (b.paused && b.reason) patch.absence_reason = b.reason - const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch)) + const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch)); if (r && r.ok) invalidateTechCache() return json(res, r.ok ? 200 : 500, { ...r, technician: techId, status: patch.status }) } // Positions GPS LIVE de TOUS les techs ayant un appareil Traccar → marqueurs « live » sur la carte des tournées @@ -1849,7 +1860,7 @@ async function handle (req, res, method, path, url) { const techName = await resolveTechName(techId) if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId }) const deviceId = (b.device_id == null || b.device_id === '') ? '' : String(b.device_id) - const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { traccar_device_id: deviceId })) + const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { traccar_device_id: deviceId })); if (r && r.ok) invalidateTechCache() return json(res, r.ok ? 200 : 500, { ...r, technician: techId, traccar_device_id: deviceId }) } // Patron d'horaire RÉCURRENT (weekly_schedule) — source des quarts matérialisés (hybride). body.schedule = {mon:{start,end}|null,…} @@ -1858,7 +1869,7 @@ async function handle (req, res, method, path, url) { const techId = decodeURIComponent(mSched[1]); const b = await parseBody(req) const techName = await resolveTechName(techId); if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId }) const sched = (b.schedule && typeof b.schedule === 'object') ? b.schedule : null - const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { weekly_schedule: sched ? JSON.stringify(sched) : '' })) + const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { weekly_schedule: sched ? JSON.stringify(sched) : '' })); if (r && r.ok) invalidateTechCache() return json(res, r.ok ? 200 : 500, { ...r, technician: techId }) } // HYBRIDE : matérialise les quarts depuis les patrons (fériés/vacances sautés, manuels préservés). GET=aperçu / POST=applique. ?weeks= ?tech=