perf(dashboard): paraleliza la carga de métricas SQL Server por nodo #11
@@ -6,7 +6,10 @@ import sql from 'mssql';
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
const MAX_POOLS = 16;
|
const MAX_POOLS = 16;
|
||||||
const poolMap = new Map<string, sql.ConnectionPool>();
|
// 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<string, Promise<sql.ConnectionPool>>();
|
||||||
|
|
||||||
export function adjustMssqlServerForDocker(serverName: string): string {
|
export function adjustMssqlServerForDocker(serverName: string): string {
|
||||||
const docker =
|
const docker =
|
||||||
@@ -52,13 +55,35 @@ async function evictPoolIfNeeded(): Promise<void> {
|
|||||||
const old = poolMap.get(first);
|
const old = poolMap.get(first);
|
||||||
poolMap.delete(first);
|
poolMap.delete(first);
|
||||||
try {
|
try {
|
||||||
await old?.close();
|
(await old)?.close();
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* 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<T, R>(
|
||||||
|
items: T[],
|
||||||
|
limit: number,
|
||||||
|
fn: (item: T, index: number) => Promise<R>
|
||||||
|
): Promise<R[]> {
|
||||||
|
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<void> {
|
||||||
|
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).
|
* 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 server = adjustMssqlServerForDocker(serverHost);
|
||||||
const key = poolCacheKey(server, user, password);
|
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 = {
|
const cfg: sql.config = {
|
||||||
user,
|
user,
|
||||||
@@ -87,9 +119,22 @@ export async function getMssqlPoolMaster(serverHost: string, password: string):
|
|||||||
connectTimeout: 30000
|
connectTimeout: 30000
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const pool = await new sql.ConnectionPool(cfg).connect();
|
|
||||||
poolMap.set(key, pool);
|
// El slot se reserva en el mapa de forma SÍNCRONA (antes de cualquier await) para que las
|
||||||
return pool;
|
// 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 = {
|
export type CatalogNodeRow = {
|
||||||
@@ -236,14 +281,37 @@ export async function loadSqlDashboardFromNodes(nodes: CatalogNodeRow[]): Promis
|
|||||||
const restoreHistory: Record<string, { restore_date: Date }[]> = {};
|
const restoreHistory: Record<string, { restore_date: Date }[]> = {};
|
||||||
let totalSizeGb = 0;
|
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();
|
const dbn = String(node.BDName || '').trim();
|
||||||
if (!dbn) continue;
|
if (!dbn) return null;
|
||||||
const pwd = resolveNodeSqlPassword(node.sql_password);
|
const pwd = resolveNodeSqlPassword(node.sql_password);
|
||||||
try {
|
try {
|
||||||
const pool = await getMssqlPoolMaster(String(node.ServerName || '').trim(), pwd);
|
const pool = await getMssqlPoolMaster(String(node.ServerName || '').trim(), pwd);
|
||||||
const row = await queryDatabaseMetricsOnServer(pool, dbn);
|
const [row, alertRow, hist] = await Promise.all([
|
||||||
if (!row) continue;
|
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 visible = String(row.visible_name ?? dbn);
|
||||||
const keyLower = visible.toLowerCase();
|
const keyLower = visible.toLowerCase();
|
||||||
@@ -261,20 +329,12 @@ export async function loadSqlDashboardFromNodes(nodes: CatalogNodeRow[]): Promis
|
|||||||
|
|
||||||
totalSizeGb += Number(row.total_size_gb) || 0;
|
totalSizeGb += Number(row.total_size_gb) || 0;
|
||||||
|
|
||||||
const alertRow = await queryDatabaseAlertRow(pool, dbn);
|
|
||||||
if (alertRow) alertsData.push(alertRow);
|
if (alertRow) alertsData.push(alertRow);
|
||||||
|
|
||||||
const hist = await queryRestoreHistoryForDatabase(pool, dbn);
|
|
||||||
if (!restoreHistory[keyLower]) restoreHistory[keyLower] = [];
|
if (!restoreHistory[keyLower]) restoreHistory[keyLower] = [];
|
||||||
for (const h of hist) {
|
for (const h of hist) {
|
||||||
restoreHistory[keyLower].push(h);
|
restoreHistory[keyLower].push(h);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error(
|
|
||||||
`SQL Server nodo id=${node.ID} server=${node.ServerName} db=${dbn}:`,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const summaryMain = {
|
const summaryMain = {
|
||||||
|
|||||||
@@ -139,8 +139,24 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
|||||||
errors.primary = `PostgreSQL / database_nodes: ${e.message}`;
|
errors.primary = `PostgreSQL / database_nodes: ${e.message}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Las métricas de SQL Server (por nodo) y el catálogo de ControlDesk (PostgreSQL) son
|
||||||
const bundle = await loadSqlDashboardFromNodes(nodesForSql);
|
// 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;
|
databaseRows = bundle.databaseRows;
|
||||||
summaryMain = bundle.summaryMain;
|
summaryMain = bundle.summaryMain;
|
||||||
alertsData = bundle.alertsData;
|
alertsData = bundle.alertsData;
|
||||||
@@ -148,24 +164,25 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
|||||||
effectivenessByDb = bundle.effectivenessByDb;
|
effectivenessByDb = bundle.effectivenessByDb;
|
||||||
databaseRowsAZ = [...bundle.databaseRows];
|
databaseRowsAZ = [...bundle.databaseRows];
|
||||||
summaryAZ = { ...bundle.summaryMain };
|
summaryAZ = { ...bundle.summaryMain };
|
||||||
} catch (e: any) {
|
} else {
|
||||||
|
const e: any = bundleResult.reason;
|
||||||
console.error('Error métricas SQL Server por nodo:', e);
|
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) ---
|
if (catalogResult.status === 'fulfilled') {
|
||||||
let controlDeskOk = false;
|
const [clients, bases, users, addEmails, authEmails, targets] = catalogResult.value;
|
||||||
try {
|
clientsData = clients;
|
||||||
clientsData = await listClientsCatalog();
|
basesDeDatosList = bases;
|
||||||
basesDeDatosList = await listDatabaseNodes();
|
usuariosList = users;
|
||||||
usuariosList = await listPortalUsers();
|
additionalEmails = addEmails;
|
||||||
additionalEmails = await listAdditionalEmails();
|
authorityEmails = authEmails;
|
||||||
authorityEmails = await listAuthorityEmails();
|
restoreTargets = targets;
|
||||||
restoreTargets = await listRestoreTargets();
|
|
||||||
controlDeskOk = true;
|
controlDeskOk = true;
|
||||||
} catch (e: any) {
|
} else {
|
||||||
|
const e: any = catalogResult.reason;
|
||||||
console.error('Error loading ControlDesk (PostgreSQL):', e);
|
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 ---
|
// --- 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) {
|
if (controlDeskOk && alertsData.length > 0) {
|
||||||
const hydratedAlerts = [];
|
alertsData = await Promise.all(
|
||||||
for (const alert of alertsData) {
|
alertsData.map(async (alert) => {
|
||||||
const nodoName = alert.visible_name;
|
|
||||||
let cData: any = null;
|
let cData: any = null;
|
||||||
try {
|
try {
|
||||||
cData = await lookupAlertClientData(String(nodoName));
|
cData = await lookupAlertClientData(String(alert.visible_name));
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
return { ...alert, clientData: cData };
|
||||||
hydratedAlerts.push({ ...alert, clientData: cData });
|
})
|
||||||
}
|
);
|
||||||
alertsData = hydratedAlerts;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enriquecer databaseRows con catálogo ControlDesk (PostgreSQL)
|
// Enriquecer databaseRows con catálogo ControlDesk (PostgreSQL)
|
||||||
if (controlDeskOk && databaseRows.length > 0) {
|
if (controlDeskOk && databaseRows.length > 0) {
|
||||||
try {
|
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<string, any>();
|
const mapByBdName = new Map<string, any>();
|
||||||
const mapByNodo = new Map<string, any>();
|
const mapByNodo = new Map<string, any>();
|
||||||
@@ -258,7 +274,6 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
|||||||
match = mapByNodo.get(key);
|
match = mapByNodo.get(key);
|
||||||
}
|
}
|
||||||
if (!match) {
|
if (!match) {
|
||||||
console.log(`No match found for database: ${key}`);
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,8 +314,10 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
|||||||
|
|
||||||
// Aplicar filtro de permisos de usuario (si no es admin)
|
// Aplicar filtro de permisos de usuario (si no es admin)
|
||||||
if (!currentUser.es_admin) {
|
if (!currentUser.es_admin) {
|
||||||
databaseRows = await filterDatabasesByUserPermissions(currentUser.id, databaseRows);
|
[databaseRows, alertsData] = await Promise.all([
|
||||||
alertsData = await filterDatabasesByUserPermissions(currentUser.id, alertsData);
|
filterDatabasesByUserPermissions(currentUser.id, databaseRows),
|
||||||
|
filterDatabasesByUserPermissions(currentUser.id, alertsData)
|
||||||
|
]);
|
||||||
|
|
||||||
// Filtrar backups según las bases de datos permitidas (usar NodoSubNodo)
|
// Filtrar backups según las bases de datos permitidas (usar NodoSubNodo)
|
||||||
const allowedNodos = new Set(databaseRows.map(db => (db.NodoSubNodo || db.visible_name).toLowerCase()));
|
const allowedNodos = new Set(databaseRows.map(db => (db.NodoSubNodo || db.visible_name).toLowerCase()));
|
||||||
|
|||||||
Reference in New Issue
Block a user