From 4a5a4093d94278a2722709ee65c5b37f86cbc553 Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Tue, 9 Jun 2026 11:21:19 -0500 Subject: [PATCH] =?UTF-8?q?perf(dashboard):=20paraleliza=20la=20carga=20de?= =?UTF-8?q?=20m=C3=A9tricas=20SQL=20Server=20por=20nodo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La carga inicial del dashboard (/+page.server.ts) tardaba ~30s porque loadSqlDashboardFromNodes recorría los nodos en serie y hacía 3 round-trips secuenciales por nodo (métricas, alerta, historial) → N×3 viajes encadenados a SQL Server remoto. Cambios: - Las 3 consultas por nodo ahora corren con Promise.all. - Los nodos se procesan con concurrencia acotada (8) vía mapWithConcurrency; la agregación se mantiene secuencial para conservar orden y evitar carreras. - getMssqlPoolMaster cachea la *promesa* del pool y reserva el slot de forma síncrona antes de cualquier await, evitando pools duplicados al paralelizar. - En +page.server.ts: métricas SQL Server y catálogo PostgreSQL se cargan en paralelo (Promise.allSettled); las 6 consultas de catálogo en Promise.all. - Se elimina la consulta listDatabaseNodes() duplicada (se reusa la ya cargada). - Hidratación de alertas y filtro de permisos paralelizados. - Se quita un console.log de depuración por fila (estándar: sin logs sueltos). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/server/mssql-nodes.ts | 134 ++++++++++++++++++++++++---------- src/routes/+page.server.ts | 81 ++++++++++++-------- 2 files changed, 146 insertions(+), 69 deletions(-) diff --git a/src/lib/server/mssql-nodes.ts b/src/lib/server/mssql-nodes.ts index 279b935..c77f1cb 100644 --- a/src/lib/server/mssql-nodes.ts +++ b/src/lib/server/mssql-nodes.ts @@ -6,7 +6,10 @@ import sql from 'mssql'; import { env } from '$env/dynamic/private'; const MAX_POOLS = 16; -const poolMap = new Map(); +// Se cachea la *promesa* del pool (no el pool ya resuelto) para que varias cargas de nodos +// concurrentes sobre el mismo servidor reutilicen una sola conexión en vuelo y no abran pools +// duplicados (condición de carrera que aparece al paralelizar el dashboard). +const poolMap = new Map>(); export function adjustMssqlServerForDocker(serverName: string): string { const docker = @@ -52,13 +55,35 @@ async function evictPoolIfNeeded(): Promise { const old = poolMap.get(first); poolMap.delete(first); try { - await old?.close(); + (await old)?.close(); } catch { /* ignore */ } } } +/** + * Ejecuta `fn` sobre cada elemento con un máximo de `limit` tareas simultáneas. + * Conserva el orden de `items` en el arreglo de resultados. + */ +async function mapWithConcurrency( + items: T[], + limit: number, + fn: (item: T, index: number) => Promise +): Promise { + const results: R[] = new Array(items.length); + let cursor = 0; + const workerCount = Math.min(Math.max(1, limit), items.length || 1); + async function worker(): Promise { + while (cursor < items.length) { + const index = cursor++; + results[index] = await fn(items[index], index); + } + } + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return results; +} + /** * Pool conectado a `master` en el servidor del nodo (permite consultar cualquier BD con nombre de tres partes). */ @@ -71,10 +96,17 @@ export async function getMssqlPoolMaster(serverHost: string, password: string): } const server = adjustMssqlServerForDocker(serverHost); const key = poolCacheKey(server, user, password); - const existing = poolMap.get(key); - if (existing?.connected) return existing; - await evictPoolIfNeeded(); + const existing = poolMap.get(key); + if (existing) { + try { + const pool = await existing; + if (pool.connected || pool.connecting) return pool; + } catch { + /* pool inservible: se descarta y se recrea abajo */ + } + poolMap.delete(key); + } const cfg: sql.config = { user, @@ -87,9 +119,22 @@ export async function getMssqlPoolMaster(serverHost: string, password: string): connectTimeout: 30000 } }; - const pool = await new sql.ConnectionPool(cfg).connect(); - poolMap.set(key, pool); - return pool; + + // El slot se reserva en el mapa de forma SÍNCRONA (antes de cualquier await) para que las + // cargas concurrentes al mismo servidor compartan esta conexión en vuelo y no creen pools + // duplicados. La purga (evictPoolIfNeeded) ocurre dentro de la promesa, no antes de registrarla. + const connecting = (async () => { + await evictPoolIfNeeded(); + return new sql.ConnectionPool(cfg).connect(); + })(); + poolMap.set(key, connecting); + try { + return await connecting; + } catch (e) { + // No conservar en caché una conexión que falló al establecerse. + poolMap.delete(key); + throw e; + } } export type CatalogNodeRow = { @@ -236,44 +281,59 @@ export async function loadSqlDashboardFromNodes(nodes: CatalogNodeRow[]): Promis const restoreHistory: Record = {}; let totalSizeGb = 0; - for (const node of nodes) { + // Para cada nodo las 3 consultas (métricas, alerta, historial) son independientes y se lanzan + // en paralelo. Los nodos se procesan con concurrencia acotada para no saturar los pools. + // Antes el patrón era N×3 round-trips en serie contra SQL Server (decenas de segundos). + const NODE_CONCURRENCY = 8; + const perNode = await mapWithConcurrency(nodes, NODE_CONCURRENCY, async (node) => { const dbn = String(node.BDName || '').trim(); - if (!dbn) continue; + if (!dbn) return null; const pwd = resolveNodeSqlPassword(node.sql_password); try { const pool = await getMssqlPoolMaster(String(node.ServerName || '').trim(), pwd); - const row = await queryDatabaseMetricsOnServer(pool, dbn); - if (!row) continue; - - const visible = String(row.visible_name ?? dbn); - const keyLower = visible.toLowerCase(); - - databaseRows.push({ - ...row, - visible_name: visible, - original_name: row.original_name ?? visible, - NodoSubNodo: node.NodoSubNodo, - client_name: node.Nombre, - BDName: dbn, - _node_id: node.ID, - _server: adjustMssqlServerForDocker(String(node.ServerName || '').trim()) - }); - - totalSizeGb += Number(row.total_size_gb) || 0; - - const alertRow = await queryDatabaseAlertRow(pool, dbn); - if (alertRow) alertsData.push(alertRow); - - const hist = await queryRestoreHistoryForDatabase(pool, dbn); - if (!restoreHistory[keyLower]) restoreHistory[keyLower] = []; - for (const h of hist) { - restoreHistory[keyLower].push(h); - } + const [row, alertRow, hist] = await Promise.all([ + queryDatabaseMetricsOnServer(pool, dbn), + queryDatabaseAlertRow(pool, dbn), + queryRestoreHistoryForDatabase(pool, dbn) + ]); + if (!row) return null; + return { node, dbn, row, alertRow, hist }; } catch (e) { console.error( `SQL Server nodo id=${node.ID} server=${node.ServerName} db=${dbn}:`, e ); + return null; + } + }); + + // Agregación secuencial sobre resultados ya resueltos: evita condiciones de carrera sobre + // las estructuras compartidas y conserva el orden original de los nodos. + for (const res of perNode) { + if (!res) continue; + const { node, dbn, row, alertRow, hist } = res; + + const visible = String(row.visible_name ?? dbn); + const keyLower = visible.toLowerCase(); + + databaseRows.push({ + ...row, + visible_name: visible, + original_name: row.original_name ?? visible, + NodoSubNodo: node.NodoSubNodo, + client_name: node.Nombre, + BDName: dbn, + _node_id: node.ID, + _server: adjustMssqlServerForDocker(String(node.ServerName || '').trim()) + }); + + totalSizeGb += Number(row.total_size_gb) || 0; + + if (alertRow) alertsData.push(alertRow); + + if (!restoreHistory[keyLower]) restoreHistory[keyLower] = []; + for (const h of hist) { + restoreHistory[keyLower].push(h); } } diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 5ad90ee..3ccf7f0 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -139,8 +139,24 @@ export const load: PageServerLoad = async ({ cookies }) => { errors.primary = `PostgreSQL / database_nodes: ${e.message}`; } - try { - const bundle = await loadSqlDashboardFromNodes(nodesForSql); + // Las métricas de SQL Server (por nodo) y el catálogo de ControlDesk (PostgreSQL) son + // independientes entre sí; se cargan en paralelo para reducir el tiempo total de la página. + // Dentro del catálogo, las 6 consultas también corren en paralelo (antes eran secuenciales). + let controlDeskOk = false; + const [bundleResult, catalogResult] = await Promise.allSettled([ + loadSqlDashboardFromNodes(nodesForSql), + Promise.all([ + listClientsCatalog(), + listDatabaseNodes(), + listPortalUsers(), + listAdditionalEmails(), + listAuthorityEmails(), + listRestoreTargets() + ]) + ]); + + if (bundleResult.status === 'fulfilled') { + const bundle = bundleResult.value; databaseRows = bundle.databaseRows; summaryMain = bundle.summaryMain; alertsData = bundle.alertsData; @@ -148,24 +164,25 @@ export const load: PageServerLoad = async ({ cookies }) => { effectivenessByDb = bundle.effectivenessByDb; databaseRowsAZ = [...bundle.databaseRows]; summaryAZ = { ...bundle.summaryMain }; - } catch (e: any) { + } else { + const e: any = bundleResult.reason; console.error('Error métricas SQL Server por nodo:', e); - errors.primary = `${errors.primary ? errors.primary + ' · ' : ''}SQL Server (nodos): ${e.message}`; + errors.primary = `${errors.primary ? errors.primary + ' · ' : ''}SQL Server (nodos): ${e?.message ?? e}`; } - // --- 3. Catálogo ControlDesk (PostgreSQL, esquema a24c) --- - let controlDeskOk = false; - try { - clientsData = await listClientsCatalog(); - basesDeDatosList = await listDatabaseNodes(); - usuariosList = await listPortalUsers(); - additionalEmails = await listAdditionalEmails(); - authorityEmails = await listAuthorityEmails(); - restoreTargets = await listRestoreTargets(); + if (catalogResult.status === 'fulfilled') { + const [clients, bases, users, addEmails, authEmails, targets] = catalogResult.value; + clientsData = clients; + basesDeDatosList = bases; + usuariosList = users; + additionalEmails = addEmails; + authorityEmails = authEmails; + restoreTargets = targets; controlDeskOk = true; - } catch (e: any) { + } else { + const e: any = catalogResult.reason; console.error('Error loading ControlDesk (PostgreSQL):', e); - errors.azure = `Error conectando al catálogo ControlDesk (PostgreSQL): ${e.message}`; + errors.azure = `Error conectando al catálogo ControlDesk (PostgreSQL): ${e?.message ?? e}`; } // --- 4. Process Backups, Hydrate Alerts & Enriquecer databaseRows con datos de BasesDeDatos --- @@ -219,25 +236,24 @@ export const load: PageServerLoad = async ({ cookies }) => { }); if (controlDeskOk && alertsData.length > 0) { - const hydratedAlerts = []; - for (const alert of alertsData) { - const nodoName = alert.visible_name; - let cData: any = null; - try { - cData = await lookupAlertClientData(String(nodoName)); - } catch { - /* ignore */ - } - - hydratedAlerts.push({ ...alert, clientData: cData }); - } - alertsData = hydratedAlerts; + alertsData = await Promise.all( + alertsData.map(async (alert) => { + let cData: any = null; + try { + cData = await lookupAlertClientData(String(alert.visible_name)); + } catch { + /* ignore */ + } + return { ...alert, clientData: cData }; + }) + ); } // Enriquecer databaseRows con catálogo ControlDesk (PostgreSQL) if (controlDeskOk && databaseRows.length > 0) { try { - const bases = (await listDatabaseNodes()) as any[]; + // Reutiliza el catálogo ya cargado en el bloque anterior (evita re-consultar PostgreSQL). + const bases = basesDeDatosList as any[]; const mapByBdName = new Map(); const mapByNodo = new Map(); @@ -258,7 +274,6 @@ export const load: PageServerLoad = async ({ cookies }) => { match = mapByNodo.get(key); } if (!match) { - console.log(`No match found for database: ${key}`); return row; } @@ -299,8 +314,10 @@ export const load: PageServerLoad = async ({ cookies }) => { // Aplicar filtro de permisos de usuario (si no es admin) if (!currentUser.es_admin) { - databaseRows = await filterDatabasesByUserPermissions(currentUser.id, databaseRows); - alertsData = await filterDatabasesByUserPermissions(currentUser.id, alertsData); + [databaseRows, alertsData] = await Promise.all([ + filterDatabasesByUserPermissions(currentUser.id, databaseRows), + filterDatabasesByUserPermissions(currentUser.id, alertsData) + ]); // Filtrar backups según las bases de datos permitidas (usar NodoSubNodo) const allowedNodos = new Set(databaseRows.map(db => (db.NodoSubNodo || db.visible_name).toLowerCase())); -- 2.49.1