203 lines
7.0 KiB
JavaScript
203 lines
7.0 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Benchmark de red para el buscador: compara la latencia del patrón de
|
|
* búsqueda ANTES de las optimizaciones (varias requests secuenciales) contra
|
|
* el patrón DESPUÉS (una sola request con join), golpeando directamente el
|
|
* cluster real de Typesense — sin pasar por el navegador ni por Nuxt.
|
|
*
|
|
* Sirve como línea base repetible: correr este script antes/después de un
|
|
* cambio futuro en las queries de búsqueda muestra si mejoró o empeoró la
|
|
* latencia real contra el servidor, no solo "se siente más rápido".
|
|
*
|
|
* Uso:
|
|
* node --env-file=.env scripts/benchmark-search.mjs
|
|
* node --env-file=.env scripts/benchmark-search.mjs --iterations 20 --query "amor"
|
|
* pnpm run benchmark:search -- --iterations 20
|
|
*
|
|
* Requiere NUXT_PUBLIC_TYPESENSE_URL y NUXT_PUBLIC_TYPESENSE_API_KEY en el
|
|
* entorno (--env-file=.env los carga automáticamente en Node 20.6+).
|
|
*/
|
|
|
|
const args = process.argv.slice(2)
|
|
function argValue(name, fallback) {
|
|
const idx = args.indexOf(`--${name}`)
|
|
return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback
|
|
}
|
|
|
|
const ITERATIONS = Number(argValue('iterations', '15'))
|
|
const QUERY = argValue('query', 'amor')
|
|
const LOCALE = argValue('locale', 'es')
|
|
|
|
const TYPESENSE_URL = process.env.NUXT_PUBLIC_TYPESENSE_URL
|
|
const API_KEY = process.env.NUXT_PUBLIC_TYPESENSE_API_KEY
|
|
|
|
if (!TYPESENSE_URL || !API_KEY) {
|
|
console.error('Faltan NUXT_PUBLIC_TYPESENSE_URL / NUXT_PUBLIC_TYPESENSE_API_KEY.')
|
|
console.error('Corré con: node --env-file=.env scripts/benchmark-search.mjs')
|
|
process.exit(1)
|
|
}
|
|
|
|
async function multiSearch(searches) {
|
|
const res = await fetch(`${TYPESENSE_URL}/multi_search`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-TYPESENSE-API-KEY': API_KEY
|
|
},
|
|
body: JSON.stringify({ searches })
|
|
})
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`)
|
|
return res.json()
|
|
}
|
|
|
|
async function timeIt(fn) {
|
|
const start = performance.now()
|
|
await fn()
|
|
return performance.now() - start
|
|
}
|
|
|
|
function stats(samples) {
|
|
const sorted = [...samples].sort((a, b) => a - b)
|
|
const sum = sorted.reduce((a, b) => a + b, 0)
|
|
const p = q => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]
|
|
return {
|
|
mean: sum / sorted.length,
|
|
median: p(0.5),
|
|
p95: p(0.95),
|
|
min: sorted[0],
|
|
max: sorted[sorted.length - 1]
|
|
}
|
|
}
|
|
|
|
async function runScenario(name, fn) {
|
|
const samples = []
|
|
// Un warmup fuera de la medición, para no medir handshake TLS/DNS frío.
|
|
await fn().catch(() => {})
|
|
for (let i = 0; i < ITERATIONS; i++) {
|
|
samples.push(await timeIt(fn))
|
|
}
|
|
return { name, ...stats(samples) }
|
|
}
|
|
|
|
// ── Escenarios por colección (conferences / activities) ─────────────────────
|
|
|
|
const COLLECTIONS = [
|
|
{ label: 'conferences', main: 'conferences', paragraphs: 'conferences_paragraphs', groupBy: 'conferences_id' },
|
|
{ label: 'activities', main: 'activities', paragraphs: 'activities_paragraphs', groupBy: 'activities_id' }
|
|
]
|
|
|
|
function oldSearchScenario({ main, paragraphs, groupBy }) {
|
|
return async () => {
|
|
// 1) búsqueda de párrafos, SIN join (como antes de la Fase 1.1)
|
|
const r1 = await multiSearch([{
|
|
collection: paragraphs,
|
|
q: QUERY,
|
|
query_by: 'text',
|
|
filter_by: `locale:=${LOCALE}`,
|
|
per_page: 10,
|
|
highlight_full_fields: 'text',
|
|
highlight_fields: 'text',
|
|
highlight_affix_num_tokens: 30,
|
|
group_by: groupBy
|
|
}])
|
|
const ids = (r1.results?.[0]?.grouped_hits ?? [])
|
|
.map(g => g.group_key?.[0])
|
|
.filter(Boolean)
|
|
if (!ids.length) return
|
|
// 2) segunda request para la metadata (la cascada eliminada en 1.1)
|
|
await multiSearch([{
|
|
collection: main,
|
|
q: '*',
|
|
query_by: 'title',
|
|
filter_by: `id:=[${ids.join(',')}]`,
|
|
per_page: ids.length,
|
|
include_fields: 'id,title,date,timestamp,place,city,state,country,type,slug,draft'
|
|
}])
|
|
}
|
|
}
|
|
|
|
function newSearchScenario({ main, paragraphs, groupBy }) {
|
|
return async () => {
|
|
// Una sola request: join a la colección principal + highlight recortado
|
|
await multiSearch([{
|
|
collection: paragraphs,
|
|
q: QUERY,
|
|
query_by: 'text',
|
|
filter_by: `locale:=${LOCALE}`,
|
|
per_page: 10,
|
|
highlight_full_fields: 'text',
|
|
highlight_fields: 'text',
|
|
highlight_affix_num_tokens: 15,
|
|
group_by: groupBy,
|
|
include_fields: `*, $${main}(id,title,date,timestamp,place,city,state,country,type,slug,draft)`
|
|
}])
|
|
}
|
|
}
|
|
|
|
function oldBrowseScenario({ main, paragraphs, groupBy }) {
|
|
return async () => {
|
|
// Explorar sin query contra la colección grande de párrafos (antes de 1.2)
|
|
await multiSearch([{
|
|
collection: paragraphs,
|
|
q: '*',
|
|
query_by: 'text',
|
|
filter_by: `locale:=${LOCALE} && $${main}(locale:=${LOCALE})`,
|
|
sort_by: `$${main}(timestamp:desc)`,
|
|
group_by: groupBy,
|
|
per_page: 10,
|
|
include_fields: `$${main}(id,title,date,timestamp,place,city,state,country,type,slug,draft)`
|
|
}])
|
|
}
|
|
}
|
|
|
|
function newBrowseScenario({ main }) {
|
|
return async () => {
|
|
// Explorar sin query contra la colección principal, directo (después de 1.2)
|
|
await multiSearch([{
|
|
collection: main,
|
|
q: '*',
|
|
query_by: 'title',
|
|
filter_by: `locale:=${LOCALE}`,
|
|
sort_by: 'timestamp:desc',
|
|
per_page: 10,
|
|
include_fields: 'id,title,date,timestamp,place,city,state,country,type,slug,draft'
|
|
}])
|
|
}
|
|
}
|
|
|
|
function printTable(rows) {
|
|
const cols = ['name', 'mean', 'median', 'p95', 'min', 'max']
|
|
const widths = cols.map(c => Math.max(c.length, ...rows.map(r => String(typeof r[c] === 'number' ? r[c].toFixed(1) : r[c]).length)))
|
|
const fmtRow = vals => vals.map((v, i) => String(v).padEnd(widths[i])).join(' ')
|
|
console.log(fmtRow(cols.map(c => c.toUpperCase())))
|
|
console.log(widths.map(w => '-'.repeat(w)).join(' '))
|
|
for (const r of rows) {
|
|
console.log(fmtRow(cols.map(c => (typeof r[c] === 'number' ? r[c].toFixed(1) + 'ms' : r[c]))))
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
console.log(`Typesense: ${TYPESENSE_URL} | query="${QUERY}" | locale=${LOCALE} | iteraciones=${ITERATIONS}\n`)
|
|
|
|
for (const col of COLLECTIONS) {
|
|
console.log(`\n=== ${col.label} — búsqueda con texto ===`)
|
|
const oldR = await runScenario('antes (2 requests)', oldSearchScenario(col))
|
|
const newR = await runScenario('después (1 request)', newSearchScenario(col))
|
|
printTable([oldR, newR])
|
|
const improvement = ((oldR.mean - newR.mean) / oldR.mean * 100).toFixed(1)
|
|
console.log(`→ ${improvement}% más rápido en promedio`)
|
|
|
|
console.log(`\n=== ${col.label} — explorar sin query ===`)
|
|
const oldB = await runScenario('antes (colección párrafos)', oldBrowseScenario(col))
|
|
const newB = await runScenario('después (colección principal)', newBrowseScenario(col))
|
|
printTable([oldB, newB])
|
|
const improvementB = ((oldB.mean - newB.mean) / oldB.mean * 100).toFixed(1)
|
|
console.log(`→ ${improvementB}% más rápido en promedio`)
|
|
}
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error('Error corriendo el benchmark:', err)
|
|
process.exit(1)
|
|
})
|