const fs = require('fs'); const path = require('path'); const { createClient, readCsv, writeJson } = require('./benchmark-lib'); const { explainSummary, pageFields, query02Dir, query02Sql } = require('./query02-lib'); const rootDir = path.resolve(__dirname, '..', '..'); const reportFile = path.join(rootDir, 'benchmarks', 'INFORME_OPTIMIZACION_QUERY02.md'); const readJsonIfExists = (file) => (fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : null); const fmt = (value, suffix = '') => { if (value === null || value === undefined || value === '') return 'N/D'; const number = Number(value); if (!Number.isFinite(number)) return `${value}${suffix}`; return `${number.toLocaleString('es-CO', { maximumFractionDigits: 4 })}${suffix}`; }; const findRow = (rows, queryId, level = null) => rows.find( (row) => row.query_id === queryId && (level === null || String(row.concurrency_level) === String(level)) && !String(row.phase || '').endsWith('_warmup') ); const improvement = (before, after) => { const b = Number(before); const a = Number(after); if (!Number.isFinite(b) || !Number.isFinite(a)) return 'N/D'; return `${fmt(b - a, ' ms')} (${fmt(((b - a) / b) * 100, ' %')})`; }; const criteriaLine = (name, value, limit, unit = 'ms') => { const ok = Number(value) <= limit; return `- ${name}: ${fmt(value, ` ${unit}`)} frente a objetivo ${fmt(limit, ` ${unit}`)} => ${ok ? 'cumple' : 'no cumple'}.`; }; const indexRows = async (client) => { const { rows } = await client.query(` SELECT c.relname AS index_name, pg_size_pretty(pg_relation_size(c.oid)) AS size, i.indisvalid, i.indisready, pg_get_indexdef(i.indexrelid) AS definition FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_class t ON t.oid = i.indrelid WHERE t.relname = 'invima_api_catalog' AND c.relname IN ( 'idx_query02_catalog_vencimiento_desc_nullslast', 'idx_invima_api_catalog_vencimiento', 'idx_invima_api_catalog_producto_prefix', 'invima_api_catalog_pk' ) ORDER BY c.relname; `); return rows; }; const tableStats = async (client) => { const { rows } = await client.query(` SELECT reltuples::bigint AS estimated_rows, pg_size_pretty(pg_relation_size('invima_api_catalog')) AS table_size, pg_size_pretty(pg_total_relation_size('invima_api_catalog')) AS total_size FROM pg_class WHERE oid = 'invima_api_catalog'::regclass; `); return rows[0]; }; const renderIndexTable = (indexes) => [ '| Indice | Tamano | Valido | Listo | Definicion |', '|---|---:|---|---|---|', ...indexes.map( (row) => `| \`${row.index_name}\` | ${row.size} | ${row.indisvalid} | ${row.indisready} | \`${row.definition.replace(/\|/g, '\\|')}\` |` ) ].join('\n'); const main = async () => { const beforeExplain = readJsonIfExists(path.join(query02Dir, 'explain_before.json')); const afterExplain = readJsonIfExists(path.join(query02Dir, 'explain_after.json')); const validation = readJsonIfExists(path.join(query02Dir, 'validation.json')); const serviceProfile = readJsonIfExists(path.join(query02Dir, 'service_profile.json')); const oldOptimized = readCsv(path.join(rootDir, 'benchmarks', 'results', 'optimized_summary.csv')); const oldConcurrency = readCsv(path.join(rootDir, 'benchmarks', 'results', 'concurrency_summary.csv')); const newOptimized = readCsv(path.join(query02Dir, 'optimized_summary.csv')); const newConcurrency = readCsv(path.join(query02Dir, 'concurrency_summary.csv')); const before = beforeExplain ? explainSummary(beforeExplain) : {}; const after = afterExplain ? explainSummary(afterExplain) : {}; const oldSeq = findRow(oldOptimized, 'query_02'); const newSeq = findRow(newOptimized, 'query_02'); const client = await createClient(); let indexes = []; let stats = {}; try { indexes = await indexRows(client); stats = await tableStats(client); } finally { await client.end(); } const concurrencyLines = [1, 5, 10, 25, 50] .map((level) => { const oldRow = findRow(oldConcurrency, 'query_02', level); const newRow = findRow(newConcurrency, 'query_02', level); return `- ${level} usuario(s): promedio ${fmt(oldRow?.latency_avg_ms, ' ms')} -> ${fmt( newRow?.latency_avg_ms, ' ms' )}; p95 ${fmt(oldRow?.latency_p95_ms, ' ms')} -> ${fmt(newRow?.latency_p95_ms, ' ms')}; throughput ${fmt( oldRow?.throughput_per_second, ' req/s' )} -> ${fmt(newRow?.throughput_per_second, ' req/s')}.`; }) .join('\n'); const validationSummary = validation?.comparison ? [ `- Total antes/despues: ${fmt(validation.comparison.totalBefore)} / ${fmt(validation.comparison.totalAfter)}.`, `- Resultado global: ${validation.comparison.valid ? 'VALIDO' : 'NO VALIDO'}.`, ...validation.comparison.pages.map( (page) => `- Offset ${fmt(page.offset)}: valido=${page.valid}; faltantes=${page.missingIds?.length ?? 'N/D'}; adicionales=${page.additionalIds?.length ?? 'N/D'}; cambio orden=${page.orderChanged}; cambio claves ORDER BY=${page.orderKeysChanged}.` ) ].join('\n') : 'No disponible.'; const report = `# Informe de optimizacion query_02 Generado: ${new Date().toISOString()} ## 1. Descripcion de query_02 \`query_02\` corresponde a la primera pagina general del catalogo INVIMA: \`\`\`sql ${query02Sql}; \`\`\` El endpoint real asociado es \`GET /api/invima/catalogo?limit=25&offset=0\`. En Angular se invoca desde \`InvimaService.searchCatalog()\` y \`CatalogPageComponent.loadData()\`. En Express pasa por \`backend/src/routes/invima.routes.js\` y termina en \`getInvimaCatalog()\` / \`runCatalogQuery()\` dentro de \`backend/invima/service.js\`. La solicitud real del endpoint ejecuta un \`COUNT(*)\` exacto y luego la consulta paginada. El benchmark historico de \`query_02\` mide la consulta paginada SQL. ## 2. Causa raiz Antes de optimizar, PostgreSQL recorria aproximadamente ${fmt(stats.estimated_rows)} filas de una tabla de ${stats.table_size || 'N/D'} y aplicaba \`top-N heapsort\` para devolver solo 25 registros. La razon principal es que el orden requerido \`fecha_vencimiento DESC NULLS LAST, producto ASC NULLS LAST\` no tenia un indice compatible. El indice existente sobre \`fecha_vencimiento\` es ascendente y no satisface directamente \`DESC NULLS LAST\`. Un indice compuesto completo con \`producto\` no es recomendable en esta base: se observaron valores de \`producto\` extremadamente largos, y btree puede superar el limite de tamano de tupla. Por eso la optimizacion usa un indice estrecho por fecha descendente y deja el orden secundario sobre \`producto\` para un conjunto reducido. ## 3. Plan anterior - Nodos: ${before.node_types || 'N/D'}. - Seq Scan: ${before.seq_scans ?? 'N/D'}; Index Scan: ${before.index_scans ?? 'N/D'}; Incremental Sort: ${before.incremental_sorts ?? 'N/D'}; Gather Merge: ${before.gather_merges ?? 'N/D'}. - Filas estimadas/real raiz: ${fmt(before.estimated_rows)} / ${fmt(before.actual_rows)}. - Ancho estimado de fila: ${fmt(before.estimated_width)} bytes. - Bloques hit/read: ${fmt(before.shared_hit_blocks)} / ${fmt(before.shared_read_blocks)}. - Temporales read/write: ${fmt(before.temp_read_blocks)} / ${fmt(before.temp_written_blocks)}. - Metodo de ordenamiento: ${(before.sort_methods || []).join(', ') || 'N/D'}. - Planeacion/ejecucion: ${fmt(before.planning_ms, ' ms')} / ${fmt(before.execution_ms, ' ms')}. ## 4. Cambios implementados Se agrego una migracion reversible: \`\`\`sql CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_query02_catalog_vencimiento_desc_nullslast ON invima_api_catalog (fecha_vencimiento DESC NULLS LAST); ALTER TABLE invima_api_catalog ALTER COLUMN fecha_vencimiento SET STATISTICS 1000; ALTER TABLE invima_api_catalog ALTER COLUMN producto SET STATISTICS 1000; \`\`\` No se modificaron columnas de respuesta, filtros, universo consultado, conteo exacto ni registros regulatorios. Se agrego un desempate estable al \`ORDER BY\` con \`source_dataset_key, source_uid\`, que son la clave primaria, para evitar duplicados u omisiones entre paginas cuando hay empates en \`fecha_vencimiento\` y \`producto\`. ## 5. Indices relevantes ${renderIndexTable(indexes)} ## 6. Plan posterior - Nodos: ${after.node_types || 'N/D'}. - Seq Scan: ${after.seq_scans ?? 'N/D'}; Index Scan: ${after.index_scans ?? 'N/D'}; Incremental Sort: ${after.incremental_sorts ?? 'N/D'}; Gather Merge: ${after.gather_merges ?? 'N/D'}. - Filas estimadas/real raiz: ${fmt(after.estimated_rows)} / ${fmt(after.actual_rows)}. - Ancho estimado de fila: ${fmt(after.estimated_width)} bytes. - Bloques hit/read: ${fmt(after.shared_hit_blocks)} / ${fmt(after.shared_read_blocks)}. - Temporales read/write: ${fmt(after.temp_read_blocks)} / ${fmt(after.temp_written_blocks)}. - Metodo de ordenamiento: ${(after.sort_methods || []).join(', ') || 'N/D'}. - Planeacion/ejecucion: ${fmt(after.planning_ms, ' ms')} / ${fmt(after.execution_ms, ' ms')}. ## 7. Validacion funcional ${validationSummary} La validacion before se ejecuto con el plan antiguo forzado mediante GUCs de sesion, y la validacion after con el nuevo indice habilitado. En ambos casos se uso la misma consulta determinista. La validacion exige que no cambien IDs, orden, claves \`ORDER BY\`, estructura publica ni valores publicos. ## 8. Resultados secuenciales - Promedio: ${fmt(oldSeq?.latency_avg_ms, ' ms')} -> ${fmt(newSeq?.latency_avg_ms, ' ms')}; mejora ${improvement( oldSeq?.latency_avg_ms, newSeq?.latency_avg_ms )}. - Mediana: ${fmt(oldSeq?.latency_median_ms, ' ms')} -> ${fmt(newSeq?.latency_median_ms, ' ms')}. - p95: ${fmt(oldSeq?.latency_p95_ms, ' ms')} -> ${fmt(newSeq?.latency_p95_ms, ' ms')}; mejora ${improvement( oldSeq?.latency_p95_ms, newSeq?.latency_p95_ms )}. - p99: ${fmt(oldSeq?.latency_p99_ms, ' ms')} -> ${fmt(newSeq?.latency_p99_ms, ' ms')}; mejora ${improvement( oldSeq?.latency_p99_ms, newSeq?.latency_p99_ms )}. - Fallos: ${fmt(newSeq?.failures)}; tasa de fallos: ${fmt(newSeq?.failure_rate_percent, ' %')}. - Throughput: ${fmt(oldSeq?.throughput_per_second, ' ops/s')} -> ${fmt(newSeq?.throughput_per_second, ' ops/s')}. ## 9. Resultados concurrentes ${concurrencyLines} ## 10. Criterios ${criteriaLine('Promedio individual', newSeq?.latency_avg_ms, 300)} ${criteriaLine('p95 individual', newSeq?.latency_p95_ms, 500)} ${criteriaLine('p99 individual', newSeq?.latency_p99_ms, 750)} ${criteriaLine('p95 con 5 usuarios', findRow(newConcurrency, 'query_02', 5)?.latency_p95_ms, 1000)} ${criteriaLine('p95 con 10 usuarios', findRow(newConcurrency, 'query_02', 10)?.latency_p95_ms, 2000)} ${criteriaLine('p95 con 25 usuarios', findRow(newConcurrency, 'query_02', 25)?.latency_p95_ms, 5000)} ## 11. Paginacion, conteo y concurrencia La consulta usa \`LIMIT 25 OFFSET n\`. Para \`OFFSET 0\`, el costo dominante era encontrar el orden inicial sin indice compatible. Las paginas profundas siguen pagando el costo natural de \`OFFSET\`; una siguiente iteracion compatible seria exponer paginacion keyset con cursor basado en \`fecha_vencimiento\`, \`producto\` y la clave primaria \`source_dataset_key/source_uid\`, manteniendo la ruta actual como compatibilidad. El endpoint real conserva \`COUNT(*)\` exacto en cada solicitud. No se reemplazo por estimaciones ni cache como unica solucion. Si el conteo se convierte en cuello de botella, se recomienda medir y separar el total exacto en una ruta o tabla de estadisticas invalidada durante sincronizacion. Perfil del servicio actualizado: \`getInvimaCatalog(pool, { limit: 25, offset: 0 })\` devolvio ${fmt( serviceProfile?.items )} filas, total exacto ${fmt(serviceProfile?.total)}, tiempo logico ${fmt( serviceProfile?.service_total_ms, ' ms' )}, serializacion ${fmt(serviceProfile?.serialization_ms, ' ms')} y respuesta de ${fmt( serviceProfile?.response_bytes, ' bytes' )}. El pool actual usa \`PGPOOL_MAX\` desde entorno y PostgreSQL tiene \`max_connections\` estandar. En una maquina de 2 vCPU y 4 GB, aumentar conexiones sin reducir el costo de query empeora la contencion; la mejora principal provino del plan SQL. ## 12. Riesgos - El nuevo indice aumenta espacio en disco y costo de mantenimiento durante inserciones/sincronizaciones. - No cubrir \`producto\` evita un indice enorme, pero todavia requiere ordenar grupos con la misma fecha. - El desempate por \`source_dataset_key, source_uid\` estabiliza la paginacion, pero cambia el orden interno previamente no garantizado de filas empatadas. - \`ANALYZE\` automatico se omitio porque esta base local tiene bytes con codificacion invalida que hacen fallar el escaneo completo. ## 13. Redaccion academica Antes de la optimizacion, la consulta general paginada presento una latencia promedio de ${fmt( oldSeq?.latency_avg_ms, ' ms' )} y un p95 de ${fmt(oldSeq?.latency_p95_ms, ' ms')}. Despues de implementar el indice \`idx_query02_catalog_vencimiento_desc_nullslast\`, la latencia promedio se redujo a ${fmt( newSeq?.latency_avg_ms, ' ms' )} y el p95 a ${fmt(newSeq?.latency_p95_ms, ' ms')}, equivalente a una mejora de ${improvement( oldSeq?.latency_avg_ms, newSeq?.latency_avg_ms )}. Bajo 50 usuarios concurrentes, la latencia promedio paso de aproximadamente ${fmt( findRow(oldConcurrency, 'query_02', 50)?.latency_avg_ms, ' ms' )} a ${fmt(findRow(newConcurrency, 'query_02', 50)?.latency_avg_ms, ' ms')}. ## 14. Reversion y repeticion Revertir: \`\`\`powershell npm.cmd run benchmark:query02:rollback \`\`\` Repetir: \`\`\`powershell npm.cmd run benchmark:query02:validate:before npm.cmd run benchmark:query02:explain:before npm.cmd run benchmark:query02:optimize npm.cmd run benchmark:query02:explain:after npm.cmd run benchmark:query02:validate npm.cmd run benchmark:query02:optimized npm.cmd run benchmark:query02:concurrency npm.cmd run benchmark:query02:report \`\`\` `; fs.writeFileSync(reportFile, report, 'utf8'); writeJson(path.join(query02Dir, 'report_metadata.json'), { generatedAt: new Date().toISOString(), reportFile }); }; main().catch((error) => { console.error(error); process.exit(1); });